Skip to content

feat(tbtc/signer): persist distributed DKG material and make it signable across sessions#4136

Merged
mswilkison merged 15 commits into
extraction/frost-signer-mirror-2026-05-26from
feat/frost-distributed-dkg-persist
Jul 8, 2026
Merged

feat(tbtc/signer): persist distributed DKG material and make it signable across sessions#4136
mswilkison merged 15 commits into
extraction/frost-signer-mirror-2026-05-26from
feat/frost-distributed-dkg-persist

Conversation

@mswilkison

@mswilkison mswilkison commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Makes a real distributed FROST DKG produce usable signing material, and makes that material signable through the interactive path — the signer half of the distributed-DKG node wiring (#4135).

  • Persist op frost_tbtc_persist_distributed_dkg_key_package: stores this node's own Part3 key package (keyed by its participant identifier) + the group public key package into engine state, derives the key group from the verifying key, and accumulates across a node's local seats. Hardened with a provenance gate, admission policy (canonical big-endian identifiers, participant-count match), quarantine enforcement, key-package consistency (identifier / min_signers == threshold / verifying-key / verifying-share-from-signing-share derivation), and idempotent-vs-conflict semantics per key group. ABI bumped to 1.1 (additive symbol; the Go bridge fail-closes against an older lib).
  • Wallet-keyed cross-session signing: DKG material is a wallet-level asset keyed by key group, not by session id. Interactive ROAST signing runs under a fresh per-message session distinct from the DKG session, so InteractiveSessionOpen / Round2 / interactive_aggregate / verify_share resolve the wallet material by key group (via the signing session's bound_key_group) and create the per-signing session on Open holding only per-signing state — no secret is copied in. Without this, distributed-DKG wallets, signable only via the interactive path, could never sign.
  • Wallet-level policy gates (emergency rekey / finalization / tx-binding) are read from the resolved wallet session at both Open and the Round2 share-release moment, so a kill switch recorded after Open still fires cross-session; trigger_emergency_rekey resolves the wallet session too (writer/reader symmetry).
  • Robustness: interactive Open honors the total-session cap like every other session-creating path; the included-participants check validates against the public key package (the full participant set), correct for a distributed node that caches only its own secret share.

Test

311 engine tests, including a cross-session persist→sign round trip (persist under session A, sign under session B → valid BIP-340 signature, signing session holds no material copy), the wallet-session kill-switch re-check across sessions, the writer redirect, the session-cap enforcement on Open, and the full persist hardening (quarantine, admission, key-package consistency, accumulate). Also proven end-to-end from Go via #4135's multi-node real-transport e2e. cargo fmt / dependency audit clean.

Companion

The Go orchestrator, node wiring, FFI bridge, and multi-node real-transport e2e are #4135. Draft until the end-to-end path lands there.

🤖 Generated with Claude Code

…aterial

The interactive signing path loads keys from the engine's persisted session
state (dkg_key_packages by member_identifier + the public key package). The
dealer run_dkg persists ALL key packages because it generates them; a REAL
distributed FROST DKG runs Part1/2/3 across nodes and each node's Part3 returns
only its OWN secret key package, which was previously discarded - so a
distributed-DKG wallet could not sign.

- Add frost_tbtc_persist_distributed_dkg_key_package (engine
  persist_distributed_dkg_key_package): store this node's single key package
  (keyed by its participant identifier) + the group public key package into the
  session, derive the key group from the verifying key, and persist. Idempotent
  for the same key group, conflicting for a different one. No production gate:
  this is the real distributed path, unlike the dealer run_dkg.
- Interactive signing OPEN validated the included participants against
  dkg_key_packages, which a distributed node only has its OWN entry in. Validate
  against the public key package's verifying shares (the full participant set)
  instead - equivalent for the dealer, correct for distributed.

Rust dkg unit tests pass; the rebuilt lib passes the keep-core real-cgo
interactive-signing and distributed-DKG tests. Go bridge + node wiring follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3a4e680-2181-4352-87e1-ad19c6eb314a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/frost-distributed-dkg-persist

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ession

persist_distributed_dkg_key_package overwrote dkg_key_packages with a single
entry and returned early on a repeat call, so a multi-seat operator persisting
several local seats of the SAME distributed DKG kept only the first seat's key
package - the others could not open an interactive signing session. Merge each
seat's key package into the session instead (same key group -> accumulate; a
different key group for the session is still a conflict). A single-seat node is
unchanged (one entry); this makes a multi-seat operator - and the single-process
end-to-end test - able to sign with every local seat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mswilkison added a commit that referenced this pull request Jul 7, 2026
… package

Wires the engine's frost_tbtc_persist_distributed_dkg_key_package FFI op into the
Go host and proves the full distributed-DKG-to-signing path end to end.

- PersistDistributedDKGKeyPackage(sessionID, participantID, threshold,
  participantCount, keyPackage, publicKeyPackage): marshals this seat's Part3 key
  package + the group public key package to the op (cgo typedef + dlsym wrapper +
  request builder + call, mirroring dkg_part3), scrubs the transport buffer, and
  returns the DKG result (key group).
- End-to-end cgo test: bus-orchestrated distributed DKG across per-seat engines ->
  persist each seat -> InteractiveSessionOpen/Round1/Round2/Aggregate -> a 2-of-3
  BIP-340 signature that verifies under the DKG group key. This is the interactive
  (persisted-state) signing path production uses, unlike the orchestrator test's
  stateless raw-key-package path. Uses canonical FROST identifiers (the persist op
  re-derives and checks them) and asserts the persisted compressed key group
  matches the DKG's x-only verifying key.

Requires the companion signer op (keep-core PR #4136). Rust suite green; the Go
cgo interactive-signing, distributed-DKG, and ROAST-retry suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… unit tests

Review follow-ups on the persist op:

- Enforce enforce_provenance_gate() before decoding or persisting any key
  material - the same gate run_dkg and every interactive op enforce. The op
  writes signing material to durable state that interactive signing trusts after
  restart, so an unattested runtime (e.g. production without a valid attestation)
  must not be able to install distributed-DKG signing material. (Codex/review P1.)
- Add engine unit tests: multi-seat key packages accumulate under one session;
  an identifier/participant mismatch and a conflicting key group are rejected;
  and the provenance gate rejects persistence when attestation is required. The
  test helper normalizes DKG material to even-Y exactly as dkg_part3 does.
- rustfmt.

Full Rust suite (303) green; the rebuilt lib keeps the keep-core distributed-DKG
persist->interactive-sign e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mswilkison mswilkison force-pushed the feat/frost-distributed-dkg-persist branch from 40cf9de to a491df8 Compare July 7, 2026 23:57
…or distributed persist (review)

Three review follow-ups on the distributed-DKG persist op:

- Enforce the DKG admission policy (min participants/threshold, required and
  allowlisted identifiers) over the participant set DERIVED from the public key
  package's verifying shares, the same gate run_dkg enforces. Refactor
  enforce_admission_policy into a shared enforce_admission_policy_for over the raw
  primitives so both paths use identical logic. Otherwise a caller could persist a
  group that omits a required participant or includes a non-allowlisted one, and
  interactive signing would later trust it. (Codex P1.)
- Validate the key package against the group public key package before storing:
  matching identifier, embedded min_signers == threshold, group verifying key, and
  this participant's verifying share. An inconsistent package (e.g. a 3-of-N key
  package stored under threshold 2) previously passed persist and burned the
  attempt at interactive Round2 share release. (Codex P2.)
- Bump the FFI ABI minor to 1 (additive symbol
  frost_tbtc_persist_distributed_dkg_key_package) and update the pinned ABI test,
  so a bridge that needs the symbol can require abi_minor >= 1 and fail-close
  against an older lib instead of failing at symbol lookup. (Codex P2.)

Adds engine tests for the threshold mismatch and the admission rejection. Full
Rust suite (304) green; the rebuilt lib keeps the keep-core distributed-DKG
persist->interactive-sign e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mswilkison added a commit that referenced this pull request Jul 8, 2026
Replaces the transitional trusted-dealer keygen (RunDKGWithSeed, hard-gated off in
production) with a real distributed FROST DKG in executeFrostDKGIfPossible.

- pkg/frost/signing: new exported seam RunDistributedDKGForSeats + capability
  interface NativeTBTCSignerDistributedDKGEngine (Part1/2/3 + persist) + helper
  CanonicalFROSTIdentifier. It converts the node's operator key to the runner's
  ephemeral key pair, builds ONE broadcast-channel DKG bus, constructs all local
  seats' runners (so every co-located seat is subscribed before any broadcasts,
  and the channel loops its own sends back), runs one orchestrator per local seat
  concurrently, persists each seat's Part3 key package, and returns the per-seat
  result (all sharing one group key).
- pkg/tbtc: executeDistributedFrostDKG builds canonical identifiers over the full
  participant set, remaps local seats to the final DKG member space, resolves the
  self operator key via node.chain.OperatorKeyPair(), runs the orchestration, and
  assembles the same dkg-persisted signer material + x-only output key the on-chain
  result assembly and per-seat signer registration already consume unchanged. The
  goroutine now calls it instead of the dealer executeFrostDKG.
- Tests: pin CanonicalFROSTIdentifier and cross-check it against the engine's own
  DeriveInteractiveAttemptContext derivation in the e2e (the identifier the persist
  op and signing path require).

Needs the signer op (keep-core PR #4136). Builds no-cgo and cgo; the distributed
DKG, persist->interactive-sign e2e, and pkg/tbtc execution tests stay green. A
multi-node orchestration run over a real channel is the remaining integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mswilkison and others added 7 commits July 7, 2026 20:37
…Ds in persist (review)

Three more review follow-ups on the distributed-DKG persist op:

- Reject any public-key-package verifying-share identifier that does not
  round-trip to a u16 participant identifier. Previously filter_map silently
  dropped a non-canonical identifier from the admission allowlist/required checks
  while it still counted toward the group, so a package could carry allowed u16
  members plus an extra non-allowlisted FROST identifier and still persist. A
  non-canonical identifier cannot be a real group member, so fail closed. (Codex P2.)
- Validate participant_count against the authoritative public-package participant
  set. A 3-member DKG could previously be installed as participant_count=2,
  leaving downstream consumers of DkgResult with the wrong group size. (Codex P2.)
- Clear the global admission-policy env at the end of the admission test (and
  before, for isolation); reset_for_tests does not clear these overrides, so the
  leak could make a later test exercise the wrong policy. (Codex P2.)

Full Rust suite (304) green; the rebuilt lib keeps the keep-core
distributed-DKG persist->interactive-sign e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… public package on accumulate (review)

Two more correctness fixes on the distributed-DKG persist op:

- Verify the SECRET signing share derives to the key package's PUBLIC verifying
  share (VerifyingShare::from(signing_share) == verifying_share). The previous
  checks only trusted the embedded verifying share, but Round2 signs with the
  signing share and deserialization does not prove the scalar matches; a corrupt
  key package could persist with a public share matching the group while storing
  an unrelated signing share, then open and burn signing attempts producing shares
  that never verify. (Codex P2.)
- On accumulate (a second local seat of the same session), require the SAME
  threshold, participant count, and public key package - not just the same group
  verifying key. Otherwise a second seat validated against a different submitted
  public package would leave the session's stored public material inconsistent
  with the newly inserted key, breaking later signing. (Codex P2.)

Adds regression tests: a crafted signing/verifying-share mismatch and a
same-key-group-different-shares accumulate. Full Rust suite (306) green; the
rebuilt lib keeps the keep-core distributed-DKG e2e + multi-node tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ibuted DKG (review)

When auto-quarantine is configured and an operator is already quarantined, the
dealer run_dkg refuses to include it (enforce_not_quarantined_identifiers), but
the distributed-DKG persist path applied only admission policy. So a distributed
DKG whose group includes a quarantined operator could be persisted and then
trusted by later interactive signing. Run the same quarantine check over the
participant set derived from the public key package before storing, honoring the
DAO allowlist exactly as run_dkg does. Adds a regression test. (Codex P2.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cross sessions

The interactive signing path required the DKG key material to live under the
per-signing session_id (SessionNotFound / DkgNotReady otherwise). But DKG persists
under the wallet's DKG session, while interactive ROAST signing runs under a fresh
per-message RoastSessionID - so a distributed-DKG wallet (signable ONLY via the
interactive path; the dealer path uses coarse re-derive) could never sign: the
signing session never held the material. Single-session tests missed it by
persisting and signing under one id.

Fix: treat DKG key material as the WALLET-level asset it is, keyed by key_group,
not by session_id. InteractiveSessionOpen resolves the wallet session by key_group
(its own session when co-located, else the session whose completed DKG produced the
key_group), reads the key material AND the wallet-level policy gates (emergency
rekey / finalization / tx-binding) from it, and creates the per-signing session on
first Open - storing ONLY per-signing state there (no secret copy). The attempt
context is still validated against request.session_id, so coordinator/attempt
derivation is unchanged (per the Go ROAST layer's model). InteractiveAggregate and
verify_share resolve the same wallet material by key_group (via the session's
bound_key_group), reading per-signing completion markers from the signing session.
DkgNotReady now means "no wallet key for this key_group".

- state.rs: SessionState.bound_key_group (transient; set at Open, not persisted -
  the in-memory attempt it serves does not survive restart either).
- interactive.rs: resolve_wallet_session_id helper; Open/Aggregate split wallet vs
  per-signing state; verify_share.rs likewise.
- New cross-session regression test (persist under session A, sign under session B,
  same key_group -> valid BIP-340 signature, and the signing session holds NO copy
  of the DKG material). Updated two tests whose SessionNotFound expectation became
  DkgNotReady under the new semantics.

Approach vetted with Codex. 308 engine tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…session at Round2

Follow-up to the cross-session key_group resolution: InteractiveSessionOpen was
updated to read the wallet-level policy gates from the resolved wallet session, but
interactive_round2 - the moment this node's secret FROST share is actually released
- was NOT. Round2 fed enforce_interactive_signing_gates the emergency_rekey_event
and finalize_request_fingerprint from the PER-SIGNING session (request.session_id).
For a distributed-DKG wallet that session is a fresh SessionState created at Open
with no policy state, while emergency rekey / finalization are recorded on the
WALLET (DKG) session. So the Round2 kill-switch re-check - which exists precisely to
stop a rekey/finalization recorded AFTER Open - silently FAILED OPEN on the only
signing path distributed-DKG wallets have: a watchtower could trigger an emergency
rekey after Open and the share would still be released.

Fix: at Round2, resolve the wallet session by the signing session's bound_key_group
(as Open/Aggregate already do) and read emergency_rekey_event /
finalize_request_fingerprint / tx_result from it. Co-located sessions resolve to
themselves, so no behavior change there.

New regression test interactive_round2_rechecks_gates_at_share_release_across_sessions:
DKG under a wallet session, sign under a DISTINCT session, record the rekey on the
wallet session -> Round2 blocks with emergency_rekey_required and does NOT consume
the nonce; clearing it lets the attempt complete. (Without the fix Round2 would
fail open and the test's expect_err would fail.) 309 engine tests green; Go
cross-session e2e still signs.

Caught by adversarial review. The refuted duplicate-key_group scan nondeterminism
(P3) stays a documented non-issue: DKG yields a fresh key per run, so at most one
session ever carries a given key_group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-rekey writer

CI fixes:
- rustfmt: reflow interactive.rs to satisfy `cargo fmt --check` (whitespace only;
  the cross-session changes were not fmt-clean).
- dependency audit: bump crossbeam-epoch 0.9.18 -> 0.9.20 for RUSTSEC-2026-0204
  (invalid pointer deref in fmt::Pointer). Dev-only transitive dep
  (criterion -> rayon -> crossbeam-deque); lockfile-only, no code/API impact.

Defense-in-depth (the writer-side counterpart to the Round2 gate fix): emergency
rekey is a WALLET-level kill switch, and interactive readers resolve it from the
wallet session by key_group. trigger_emergency_rekey keyed the event by the literal
request.session_id; if a caller passed a per-signing session id (a distinct
RoastSessionID bound to a wallet key) the event would land where no signing path
looks. Now it resolves the target to the WALLET session by key_group (a session that
already holds the DKG resolves to itself, so co-located callers are unchanged), so
writer and reader can never diverge. New test
trigger_emergency_rekey_on_signing_session_records_on_wallet_session. 310 engine
tests green.

Note: the "TLA model checks" CI failure is an unrelated infra issue - the pinned
tla2tools-v1.8.0.jar checksum drifted upstream (download hash != expected); no TLA
or script change in these PRs touches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…session

InteractiveSessionOpen creates the per-signing session on first Open (for a distinct
RoastSessionID) via entry().or_insert(), but skipped ensure_session_insert_capacity
- unlike every other session-creating path (run_dkg, persist, refresh, build_tx).
So with a fresh RoastSessionID per message the registry could grow past
TBTC_SIGNER_MAX_SESSIONS, and Round2 could then persist an over-limit registry that
the reload path rejects (persisted_engine_state_rejects_session_registry_over_limit)
- stranding the node's state. The per-member interactive cap bounds live nonces, not
total sessions, so it did not cover this.

Call ensure_session_insert_capacity before the insert (a reopen of an existing
session is exempt, so co-located callers are unchanged). New test
interactive_open_cross_session_respects_the_session_cap. 311 engine tests green.

Caught by review (Codex).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mswilkison mswilkison changed the title feat(tbtc/signer): persist a distributed DKG key package as signing material feat(tbtc/signer): persist distributed DKG material and make it signable across sessions Jul 8, 2026
mswilkison and others added 4 commits July 8, 2026 16:05
…clone; refuse cross-wallet session rebinding

Secret-lifetime scrubbing (completeness sweep of the sign path). serde deserializes
FFI request hex into OWNED Strings, independent of the C buffer the Go caller scrubs;
several held secret material and were left to drop un-wiped:
- persist_distributed_dkg_key_package: zeroize request.key_package.data_hex (the
  secret share hex) after decode, and bind+zeroize the Copy SigningShare extracted for
  the verifying-share derivation check (frost SigningShare is Copy + DefaultIsZeroes,
  not ZeroizeOnDrop).
- generate_nonces_and_commitments / sign_share: zeroize the request key_package_hex
  (secret share) and nonces_hex (one-time nonces) on success AND error paths. Mirrors
  the decoded-buffer scrubbing these ops already do.

Session isolation. A per-signing session is keyed by RoastSessionID
(message/root/start-block), NOT key_group, so two wallets signing the same digest at
the same block on a node holding members of both can collide on one session id.
InteractiveSessionOpen rebound bound_key_group unconditionally; a live member of the
first wallet would then resolve the wrong wallet material at Round2/Aggregate. Now
Open refuses to rebind a session to a different key group while any member is
mid-signing under the current one (idle sessions and same-key-group co-signers
unaffected). Test interactive_open_refuses_to_rebind_a_live_session_to_a_different_key_group.

Caught by review (Codex). 312 engine tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t request hex on all paths

P1 (session key-group isolation). InteractiveSessionOpen bound/rebound a per-signing
session to request.key_group without fully checking the session's existing wallet.
Two gaps: (a) the rebind guard keyed on live interactive_signing entries, but that set
is empty in the consumed-but-unaggregated window after a member's Round2, so a
colliding Open could still rebind bound_key_group; (b) opening a session that already
holds a DKG result for wallet A with request.key_group = wallet B installed B while
dkg_result stayed A, and later resolution prefers dkg_result, so Round2/Aggregate/
verify_share would enforce A's policy and material while signing B's share - bypassing
B's rekey/finalization gates and mis-verifying honest B shares. Both roast-session ids
are message/root/block-derived, not key_group-derived, so two wallets can collide on
one id. Fix: a session belongs to exactly ONE key group for its lifetime - reject an
Open whose key_group differs from the session's established one (dkg_result key group
if co-located, else the bound one), regardless of live members. Keeps dkg_result and
bound_key_group mutually consistent so later resolution is always correct. Tests:
...refuses_to_rebind_a_live_session... and ...refuses_to_bind_through_another_wallets_dkg_session.

P2 (secret request-String scrubbing on all paths). serde deserializes FFI request hex
into owned Strings that hold secret material (signing share, one-time nonces). The
manual scrubs landed AFTER earlier fallible checks (persist: admission/quarantine;
sign_share: signing-package/nonces decode), so an early return dropped the secret
un-wiped. Move each secret field into a Zeroizing holder up front (mem::take) so it is
wiped on EVERY return path. Applies to persist_distributed_dkg_key_package,
generate_nonces_and_commitments, and sign_share. (Per the coarse-path usage map, the
latter two have no production caller today - the interactive path supersedes them -
but the leak was real; closed uniformly.)

Caught by review (Codex). 313 engine tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s a restart

bound_key_group was reset to None on reload, on the reasoning that the live attempt
it serves does not survive a restart anyway. But InteractiveAggregate/verify_share
run AFTER a member's Round2 frees the live entry, using the durable consumed markers
plus coordinator-held shares - and they resolve the wallet by key_group. For a
distributed-DKG wallet the signing session has no dkg_result, so bound_key_group is
the ONLY link back to the wallet DKG. A restart between Round2 (shares consumed,
markers written) and InteractiveAggregate left both None -> DkgNotReady -> the
collected shares are stranded and the attempt must be fully re-run.

Persist bound_key_group alongside the consumed/aggregate markers (it is public - a key
group id, not secret; serde(default) keeps old state loadable), and restore it on
reload. This also keeps the one-key-group-per-session guard effective across a restart.
Test persisted_session_state_round_trip_preserves_bound_key_group.

Caught by review (Codex). 314 engine tests green; cross-session e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on_copy)

CI's `cargo clippy --all-targets -- -D warnings` (Signer Rust checks) failed on lints
a newer clippy enforces:
- resolve_wallet_session_id used .map_or(false, |dkg| dkg.key_group == kg); switch to
  .is_some_and(...) (unnecessary_map_or).
- the corrupt-key-package test cloned a Copy SigningShare/VerifyingKey; dereference
  instead (clone_on_copy).

No behavior change. 314 engine tests green; clippy clean locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mswilkison mswilkison marked this pull request as ready for review July 8, 2026 23:11
@mswilkison mswilkison merged commit c7dcd55 into extraction/frost-signer-mirror-2026-05-26 Jul 8, 2026
19 of 20 checks passed
@mswilkison mswilkison deleted the feat/frost-distributed-dkg-persist branch July 8, 2026 23:11
mswilkison added a commit that referenced this pull request Jul 8, 2026
…nsport + forward secrecy) (#4135)

## What

Wires a real **distributed FROST DKG** into the node, replacing the
transitional trusted-dealer keygen (`RunDKGWithSeed` →
`generate_with_dealer`, seeded by the *public* on-chain seed) that the
Rust signer hard-disables under `TBTC_SIGNER_PROFILE=production`. Each
node ends with its **own secret share** of a t-of-n key that no node
ever holds in full.

- **Orchestrator** (`RunDistributedDKGForSeats` /
`distributedDKGRunner.Run`): drives `Part1/2/3` for every local seat —
round-1 public package broadcast + collect; round-2 per-recipient sealed
packages routed + collected; `Part3` → local secret share + the agreed
group key. Every secret stays local; only public round-1 packages and
per-recipient round-2 envelopes cross the wire.
- **Real transport + authentication**: runs over the real `pkg/net`
wallet broadcast channel; every round message is
membership-authenticated against the sender's operator key (in the final
compact signing-group order). Peers learn each other's round-2 sealing
key from the authenticated round-1 broadcast.
- **Two-sided forward secrecy**: round-2 shares are ECIES-sealed to a
**fresh per-DKG ephemeral key per seat** (piggybacked on the
already-authenticated round-1 broadcast — no extra round), opened with
the ephemeral private key and scrubbed afterward. The operator key stays
with the transport and never touches the seal. All per-seat secrets
(part1/2/3 packages, round-2 shares, ephemeral keys, the persisted Part3
share) are zeroized after use.
- **Node wiring**: `executeFrostDKGIfPossible` runs the orchestrator
(gated by `KEEP_CORE_FROST_INTERACTIVE_SIGNING_ENABLED`), persists each
seat's key package as signing material, and feeds the existing on-chain
result-agreement / inactivity-challenge flow.

## Test

- **Multi-node real-transport e2e**: `n` independent nodes, each with
its own operator key, run the DKG over the real broadcast transport,
agree on **one group key** with **distinct shares**, then
interactive-sign a verifying **2-of-3 BIP-340 signature** — including
under a **per-message session distinct from the DKG session** (the
production ROAST shape).
- In-process-bus and 3-seat real-cgo orchestrator tests; net-bus
authentication / dedup / wire-format tests (incl. that round-2 seals to
the wire ephemeral key, not the operator key). Race-clean, stable across
repeated runs.

## Status / companion

Covers the orchestrator through node wiring and cross-session signing.
Remaining: multi-process e2e and deleting the dealer-DKG +
deterministic-nonce scaffolds.

The Rust signer half — the persist op, wallet-keyed cross-session
signing, and the policy-gate hardening — is #4136. The cgo integration
gate builds `libfrost_tbtc` from a signer commit pinned in
`ci/frost-signer-pin.env`, bumped in lockstep with the engine ABI. Draft
until the signer half lands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant