Skip to content

Threshold signing: consumer wiring (network integration, Rust codec, conformance vectors, DKG orchestration) - #172

Merged
Mearman merged 15 commits into
mainfrom
feat/threshold-consumer-wiring
Sep 17, 2026
Merged

Mearman merged 15 commits into
mainfrom
feat/threshold-consumer-wiring

Conversation

@Mearman

@Mearman Mearman commented Sep 17, 2026

Copy link
Copy Markdown
Member

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 wire codec (rust/crates/wire-mesh-wire/src/threshold.rs): hand-written minicbor codec for the six $manage-command-params verbs (threshold.commit/.sign/.abort, .keygen-round1/round2/confirm), mirroring webrtc.rs's own pattern -- CDE key order, strict unknown-key rejection, wired into ManageParams.
  • Conformance vectors: frames.v1.json gains vectors for all six verbs (both shapes of keygen-round1 -- fresh DKG and reshare -- plus the manage-ok extensions threshold-commit/threshold-sign responses carry), verified byte-for-byte identical between the TS cbor2 reference encoder and the Rust codec.
  • Real TS network integration for signing: createThresholdNetworkCoordinator drives ThresholdCoordinator's commitRound/signRound over a real MeshSession, mirroring webrtc-negotiation.ts. startThresholdParticipant is the answering side -- authorises each request's capability token, runs refuseUnrecognisedKind plus a caller-supplied content policy before ever returning a commitment, and mints a real threshold-share-envelope for round 2. Verified end to end: two participant responders answering over an in-process manage-request bus, driving the existing (unmodified) createThresholdIdentity to a signature that verifies against the group's own key.
  • Real TS DKG/reshare orchestration: runFreshThresholdDkg runs a device's full DKG choreography (broadcast round 1, exchange round 2 pairwise, local round 3, echo-broadcast confirm) over real manage-request traffic. computeReshareContribution/sendReshareContribution/joinThresholdReshare do 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.
  • Real Rust network integration: NetworkThresholdCoordinator implements the existing ThresholdCoordinator trait (identity.rs, from core/threshold: FROST-based threshold signing for a person's own devices #29's own PR) over a caller-supplied ManageRequestSender. handle_threshold_commit/handle_threshold_sign/handle_threshold_abort are the participant-side handlers. Verified with a real commit_round/sign_round round trip producing a signature that aggregates and verifies.
  • Along the way: split_commitments/combine_commitments (signing), split_round1_package/combine_round1_package (DKG), split_commitment/combine_commitment_parts (reshare), and a reshare-specific transcript_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 actual wire-mesh-core source before starting, not assumed. NetworkThresholdCoordinator and the handle_threshold_* handlers are the real, tested building blocks such a runtime would call, but there is no Rust equivalent of runFreshThresholdDkg/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 had mesh-session.ts to build against.

Verification

just build && just test && just lint && just typecheck && just conformance all green locally, including the wasm32-unknown-unknown target (confirmed unaffected by the new Rust network module, which deliberately uses std::sync::Mutex rather than pulling in tokio's net/rt-multi-thread features). CI green on the final commit (CDDL Validate, Conformance Verify, rust/ Verify, ts/ Verify, web-console E2E).

Closes #171

…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
Mearman marked this pull request as ready for review September 17, 2026 18:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-17T19:01:55.441570Z 2db68ae Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 6912d96 into main Sep 17, 2026
7 checks passed
@Mearman
Mearman deleted the feat/threshold-consumer-wiring branch September 17, 2026 18:51
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.

Threshold signing: consumer wiring (network integration, Rust codec, conformance vectors, DKG orchestration)

1 participant