Threshold signing: consumer wiring (network integration, Rust codec, conformance vectors, DKG orchestration) - #172
Merged
Merged
Conversation
…and verbs Adds ThresholdCommit/ThresholdSign/ThresholdAbort and the collapsed ThresholdKeygenRound1/Round2/Confirm triplet, mirroring webrtc.rs's own pattern: minicbor Encode/Decode with fields written in CDE key order (encoded length first, then bytewise), strict unknown-key rejection, and a private param::decode_params_map helper matching webrtc/exec. Wires all six into ManageParams so a namespaced-domain caller gets typed structs instead of falling through to the generic Json arm, exactly the gap PR #166 left open. CDE key order was computed by hand from each CDDL rule's field names and verified against round-trip tests rather than assumed.
…erbs Covers threshold.commit/.sign/.abort (with and without reason), both shapes of threshold.keygen-round1 (fresh DKG with proof-of-knowledge, reshare with existing-group-key), threshold.keygen-round2/-confirm, and the manage-ok extensions threshold-commit/threshold-sign responses carry (participant/hiding/binding, and a nested threshold-share-envelope). Verified against both the TS cbor2 reference encoder (pnpm test, 61/61) and the hand-written Rust codec (cargo run -p wire-mesh-conformance, 61/61), pinning that the two implementations agree byte-for-byte on the new verbs' CDE layout.
…ersistence Mirrors wire_mesh_threshold::nonce_store::NonceStore on the Rust side: persist/take/discard with a one-shot take contract, so releasing two signature shares from one nonce pair is structurally unrepresentable rather than merely discouraged. createMemoryNonceStore is the in-process reference implementation for tests and single-process development.
A round-2 signature share wrapped in its own cose-sign1 signed under the releasing participant's own personal device key, never the group's, so misbehaviour is publicly provable rather than merely locally identifiable to the coordinator. Reuses tokens.ts's own sig1ToBeSigned/self-certification pattern rather than a second hand-rolled construction, mirroring wire_mesh_threshold::share_envelope on the Rust side. Adds generateEd25519Identity to the shared token test fixtures alongside the existing ES256 one, since a threshold-share-envelope's personal signing key is Ed25519.
…ld verbs Mirrors webrtc-signaling.ts's own split between pure wire logic and a transport-consuming adapter: verb/scope constants, isThresholdCommit and its five siblings, and one builder per verb. keygenCapabilityVerb derives :keygen vs :reshare from existing-group-key's presence on round1, per threshold.cddl's own verifier obligation -- callers thread the same fresh-vs-reshare choice through round2/confirm explicitly rather than re-inferring it from wire shapes that no longer carry the distinguishing field.
…and binding threshold-commitment's own wire shape carries hiding and binding as two separate bstr fields, but frost-core's SigningCommitments::serialize() produces one opaque combined blob -- there was no way to actually construct or parse a spec-shaped threshold-commitment until now. split_commitments/combine_commitments (wire-mesh-threshold::signing) use SigningCommitments' own hiding()/binding() getters and NonceCommitment's own serialize/deserialize, wired through to WASM and to TypeScript's splitCommitments/combineCommitments so both sides of the network layer can speak the real wire format rather than a private blob.
…network domain
Names every previously-bare numeric/byte-array literal that no-magic-numbers
flags, fixes a real type-narrowing gap in generateEd25519Identity (Node's
generateKey overloads mis-resolve a bare { name: "Ed25519" } to the
CryptoKey-only overload because it structurally matches KmacKeyGenParams,
whose length field is optional), and escapes a tsdoc comment's stray "=>".
…onse traffic createThresholdNetworkCoordinator drives ThresholdCoordinator's commitRound/signRound over a real MeshSession, mirroring how webrtc-negotiation.ts drives core/webrtc over sendManageRequest. startThresholdParticipant is the answering side: consumes incomingManageRequests, authorises each request's capability token, runs a subject through refuseUnrecognisedKind plus a caller-supplied policy before ever returning a commitment, and mints a real threshold-share-envelope for round 2. threshold-share-wire.ts handles the "outer bstr, nested CBOR" encoding threshold-sign's own share field carries, the same pattern token-claims.parent already uses for an embedded self-certifying structure. Verified end to end: a real T=2-of-3 group, two participant responders answering over an in-process manage-request bus, and the existing (unmodified) createThresholdIdentity driving the whole commit/sign round trip to a signature that verifies against the group's own key.
…nd proof-of-knowledge threshold-keygen-round1's own wire shape carries the Feldman commitment as an array of independently-serialized coefficients and the Schnorr proof of knowledge as a separate byte string, but frost-core's own Package::serialize() produces one opaque combined blob -- the same gap signing's own hiding/binding split already closed, now closed for DKG's round-1 package too. split_round1_package/combine_round1_package (wire_mesh_threshold::dkg) use Package's own commitment()/proof_of_knowledge() getters and VerifiableSecretSharingCommitment's own per-coefficient serialize/ deserialize, wired through to WASM and to TypeScript's splitRound1Package/combineRound1Package.
runFreshThresholdDkg is the session-orchestration layer ThresholdIdentity/ ThresholdCoordinator already provide for signing, extended to DKG: a caller previously had to drive dkgRound1/dkgRound2/dkgRound3/ dkgTranscriptDigest/dkgConfirmMatches by hand. Every device in a ceremony calls this once with the identical session-id and participant set; it broadcasts round 1, exchanges round 2 pairwise, computes round 3 locally, then confirms via echo-broadcast, aborting the whole ceremony (rather than repairing it in place) on any digest or group-key mismatch. Distinguishing which peer sent an incoming round1/round2/confirm message needs IncomingManageRequest.fromDevice, which MeshSession only populates for a relay-routed request -- the same reliance webrtc-negotiation.ts's own handleIncomingOffer already has for addressing a specific peer. Verified with three devices running the real choreography concurrently over a relay-shaped in-process bus: all three derive the identical group key, and the resulting key packages actually sign, with the aggregate verifying against the group's own key via ordinary Web Crypto.
…e-shaped parts threshold-keygen-round1's commitment field is the same [* bstr] array shape for a reshare as for a fresh DKG, but round1_reshare's own output is a single whole-blob serialization (VerifiableSecretSharingCommitment:: serialize_whole), the format combine_survivor_commitments and derive_public_key_package already expect internally. split_commitment/combine_commitment_parts (wire_mesh_threshold::reshare) convert between that whole-blob representation and the wire's own per-coefficient array with no change to round1_reshare/ combine_survivor_commitments/derive_public_key_package, wired through to WASM and to TypeScript's reshareSplitCommitment/ reshareCombineCommitmentParts.
…licKeyPackage A reshare's own verifier obligation (spec/threshold.cddl) requires checking the newly-derived group key against existing-group-key -- "a reshare that changes the group key is a takeover, not a reshare" -- but reshare_derive_public_key_package only ever returned the serialized PublicKeyPackage blob, with no way to extract just the verifying key bytes threshold-keygen-confirm's own group-key field carries. Mirrors DkgRound3Output's identical shape for fresh DKG: the wasm output now carries publicKeyPackage and groupVerifyingKey side by side, wired through to TypeScript's ReshareDerivePublicKeyPackageResult.
threshold-keygen-confirm's echo-broadcast round needs a transcript digest over the T survivors' own reshare commitments, but the only digest function available (dkg::transcript_digest) deserializes each entry as a fresh-DKG round-1 Package (commitment + proof-of-knowledge) -- a reshare commitment carries no such structure, so reusing it fails outright with an opaque "Error deserializing value". reshare::transcript_digest hashes each survivor's own whole-blob commitment directly instead, mirroring dkg::transcript_digest's exact hashing scheme (device-id, then the entry's own canonical bytes, then the derived group verifying key) without requiring a DKG-shaped package. Wired through to WASM and to TypeScript's reshareTranscriptDigest.
computeReshareContribution/sendReshareContribution/joinThresholdReshare extend the fresh-DKG orchestration layer to resharing: a caller no longer drives reshareRound1/reshareCombineCommitments/ reshareDerivePublicKeyPackage/reshareCombineReceivedShares by hand. Resharing has no single symmetric choreography the way fresh DKG does -- only the T survivors deal, and every member of the new participant set (survivors staying on and brand-new joiners alike) independently collects their broadcasts, derives the reshared public key package (refusing to adopt one whose verifying key doesn't match existing-group-key -- a reshare that changes the group key is a takeover, never adopted), and confirms via the same echo-broadcast digest exchange fresh DKG uses. computeReshareContribution is split out from the network send (sendReshareContribution) specifically so a device that is both a survivor and a new participant can start listening (joinThresholdReshare) concurrently with its own broadcast sends -- two such devices awaiting each other's sends before either starts listening deadlock otherwise, confirmed directly while building the end-to-end test below. Verified with four devices across two ceremonies: a fresh T=2-of-3 DKG, then a reshare to a different T=2-of-3 committee (one device dropped, one added) that preserves the group key and produces shares from two devices that never held a share together before the reshare, verified against the original group key with ordinary Web Crypto.
…o real manage-request traffic NetworkThresholdCoordinator implements the existing ThresholdCoordinator trait (identity.rs, from #29's own PR) over a caller-supplied ManageRequestSender, driving commit_round/sign_round through real threshold.commit/.sign manage-commands instead of the in-process direct calls every existing test uses. sign_round's own commitments are keyed by FROST Identifier, not device-id -- deriving one from the other is one-way, so the coordinator caches the mapping commit_round already built, per session-id, consumed by the matching sign_round call. handle_threshold_commit/handle_threshold_sign/handle_threshold_abort are the participant-side counterparts: pure handlers a real dispatch loop calls once it has decoded a ManageParams::Threshold* variant. This codebase has no live Rust node/session runtime at all yet (no equivalent of mesh-session.ts's own incoming-request loop), so these are the building blocks such a runtime would call, not a runnable loop in their own right -- documented directly in the module's own doc comment rather than left implicit. std::sync::Mutex, not tokio::sync::Mutex: this crate deliberately keeps wire-mesh-core's own "net" feature off so wire-mesh-threshold-wasm's wasm32-unknown-unknown build succeeds (mio has no support for that target) -- pulling in workspace tokio's "net"/"rt-multi-thread" features for an async mutex would have broken that. The coordinator's own critical sections are synchronous (a HashMap insert/remove), so a std Mutex is both correct and avoids the dependency entirely; confirmed the wasm32 target still builds after adding this module. Verified end to end with a two-signer T=2 group: a real commit_round/ sign_round round trip over a fake ManageRequestSender routing directly to handle_threshold_commit/handle_threshold_sign, producing shares that aggregate into a signature verifying against the group's own key.
Mearman
marked this pull request as ready for review
September 17, 2026 18:50
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
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.
Implements #171, the four follow-up pieces PR #166 (#29) left open: the domain layer is real and tested, this wires it to an actual consumer.
What's here
rust/crates/wire-mesh-wire/src/threshold.rs): hand-writtenminicborcodec for the six$manage-command-paramsverbs (threshold.commit/.sign/.abort,.keygen-round1/round2/confirm), mirroringwebrtc.rs's own pattern -- CDE key order, strict unknown-key rejection, wired intoManageParams.frames.v1.jsongains vectors for all six verbs (both shapes ofkeygen-round1-- fresh DKG and reshare -- plus themanage-okextensionsthreshold-commit/threshold-signresponses carry), verified byte-for-byte identical between the TScbor2reference encoder and the Rust codec.createThresholdNetworkCoordinatordrivesThresholdCoordinator'scommitRound/signRoundover a realMeshSession, mirroringwebrtc-negotiation.ts.startThresholdParticipantis the answering side -- authorises each request's capability token, runsrefuseUnrecognisedKindplus a caller-supplied content policy before ever returning a commitment, and mints a realthreshold-share-envelopefor round 2. Verified end to end: two participant responders answering over an in-process manage-request bus, driving the existing (unmodified)createThresholdIdentityto a signature that verifies against the group's own key.runFreshThresholdDkgruns a device's full DKG choreography (broadcast round 1, exchange round 2 pairwise, local round 3, echo-broadcast confirm) over realmanage-requesttraffic.computeReshareContribution/sendReshareContribution/joinThresholdResharedo the same for resharing, handling the asymmetry (only survivors deal; every member of the new participant set independently derives and confirms). Verified with a full T=2-of-3 DKG followed by a reshare to a different T=2-of-3 committee (one device dropped, one added), producing shares from two devices that never held a share together before the reshare -- verified against the original group key.NetworkThresholdCoordinatorimplements the existingThresholdCoordinatortrait (identity.rs, from core/threshold: FROST-based threshold signing for a person's own devices #29's own PR) over a caller-suppliedManageRequestSender.handle_threshold_commit/handle_threshold_sign/handle_threshold_abortare the participant-side handlers. Verified with a realcommit_round/sign_roundround trip producing a signature that aggregates and verifies.split_commitments/combine_commitments(signing),split_round1_package/combine_round1_package(DKG),split_commitment/combine_commitment_parts(reshare), and a reshare-specifictranscript_digest-- all closing real gaps between frost-core's own combined-blob serialization and the wire's own split field shapes, each with its own WASM binding and TS wrapper.Known, deliberate scope limit
This codebase has no live Rust node/session runtime at all yet (no equivalent of
mesh-session.ts's own incoming-request dispatch loop) -- confirmed by reading the actualwire-mesh-coresource before starting, not assumed.NetworkThresholdCoordinatorand thehandle_threshold_*handlers are the real, tested building blocks such a runtime would call, but there is no Rust equivalent ofrunFreshThresholdDkg/joinThresholdReshare(DKG/reshare orchestration) on the Rust side, since building one would mean inventing that missing session runtime first -- a separate, much larger undertaking outside this issue's own scope. The TS side has no such gap: it already hadmesh-session.tsto build against.Verification
just build && just test && just lint && just typecheck && just conformanceall green locally, including thewasm32-unknown-unknowntarget (confirmed unaffected by the new Rust network module, which deliberately usesstd::sync::Mutexrather than pulling intokio'snet/rt-multi-threadfeatures). CI green on the final commit (CDDL Validate, Conformance Verify,rust/Verify,ts/Verify, web-console E2E).Closes #171