feat(tbtc/signer): persist distributed DKG material and make it signable across sessions#4136
Merged
mswilkison merged 15 commits intoJul 8, 2026
Conversation
…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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
…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>
40cf9de to
a491df8
Compare
…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>
…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>
…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>
c7dcd55
into
extraction/frost-signer-mirror-2026-05-26
19 of 20 checks passed
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
frost_tbtc_persist_distributed_dkg_key_package: stores this node's ownPart3key 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).InteractiveSessionOpen/Round2/interactive_aggregate/verify_shareresolve the wallet material by key group (via the signing session'sbound_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.trigger_emergency_rekeyresolves the wallet session too (writer/reader symmetry).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