From ec5c0909759f95cee0c488b3d1070fc81cc4b2a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:47:58 +0100 Subject: [PATCH 01/15] feat(wire): hand-written Rust codec for the six threshold manage-command 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. --- rust/crates/wire-mesh-wire/src/lib.rs | 1 + rust/crates/wire-mesh-wire/src/management.rs | 34 + rust/crates/wire-mesh-wire/src/threshold.rs | 778 +++++++++++++++++++ 3 files changed, 813 insertions(+) create mode 100644 rust/crates/wire-mesh-wire/src/threshold.rs diff --git a/rust/crates/wire-mesh-wire/src/lib.rs b/rust/crates/wire-mesh-wire/src/lib.rs index 40d2e88..20baab3 100644 --- a/rust/crates/wire-mesh-wire/src/lib.rs +++ b/rust/crates/wire-mesh-wire/src/lib.rs @@ -38,6 +38,7 @@ pub mod management; pub mod room; pub mod streaming; pub mod strict; +pub mod threshold; pub mod tokens; pub mod transport; pub mod value; diff --git a/rust/crates/wire-mesh-wire/src/management.rs b/rust/crates/wire-mesh-wire/src/management.rs index 797adb7..0e4c187 100644 --- a/rust/crates/wire-mesh-wire/src/management.rs +++ b/rust/crates/wire-mesh-wire/src/management.rs @@ -15,6 +15,10 @@ use crate::exec::{ }; use crate::identity::{device_id_from, DeviceId, IdentityKey}; use crate::strict; +use crate::threshold::{ + ThresholdAbort, ThresholdCommit, ThresholdKeygenConfirm, ThresholdKeygenRound1, + ThresholdKeygenRound2, ThresholdSign, +}; use crate::tokens::{scope_from, CapabilityScope, CapabilityVerb, CoseSign1}; use crate::value::{CanonicalMap, CborValue, CdeKey, CdeMapBuilder}; use crate::webrtc::{WebrtcAnswer, WebrtcIceCandidate, WebrtcOffer}; @@ -83,6 +87,12 @@ pub enum ManageParams { WebrtcOffer(WebrtcOffer), WebrtcAnswer(WebrtcAnswer), WebrtcIceCandidate(WebrtcIceCandidate), + ThresholdCommit(ThresholdCommit), + ThresholdSign(ThresholdSign), + ThresholdAbort(ThresholdAbort), + ThresholdKeygenRound1(ThresholdKeygenRound1), + ThresholdKeygenRound2(ThresholdKeygenRound2), + ThresholdKeygenConfirm(ThresholdKeygenConfirm), /// An unrecognised command verb: the params map exactly as sent, `* tstr => any`. Json(CanonicalMap), } @@ -102,6 +112,12 @@ impl ManageParams { ManageParams::WebrtcOffer(_) => WebrtcOffer::VERB, ManageParams::WebrtcAnswer(_) => WebrtcAnswer::VERB, ManageParams::WebrtcIceCandidate(_) => WebrtcIceCandidate::VERB, + ManageParams::ThresholdCommit(_) => ThresholdCommit::VERB, + ManageParams::ThresholdSign(_) => ThresholdSign::VERB, + ManageParams::ThresholdAbort(_) => ThresholdAbort::VERB, + ManageParams::ThresholdKeygenRound1(_) => ThresholdKeygenRound1::VERB, + ManageParams::ThresholdKeygenRound2(_) => ThresholdKeygenRound2::VERB, + ManageParams::ThresholdKeygenConfirm(_) => ThresholdKeygenConfirm::VERB, ManageParams::Json(map) => map .get(&"verb".to_owned()) .and_then(|v| match v { @@ -131,6 +147,12 @@ impl Encode<()> for ManageParams { ManageParams::WebrtcOffer(p) => p.encode(e, &mut ()), ManageParams::WebrtcAnswer(p) => p.encode(e, &mut ()), ManageParams::WebrtcIceCandidate(p) => p.encode(e, &mut ()), + ManageParams::ThresholdCommit(p) => p.encode(e, &mut ()), + ManageParams::ThresholdSign(p) => p.encode(e, &mut ()), + ManageParams::ThresholdAbort(p) => p.encode(e, &mut ()), + ManageParams::ThresholdKeygenRound1(p) => p.encode(e, &mut ()), + ManageParams::ThresholdKeygenRound2(p) => p.encode(e, &mut ()), + ManageParams::ThresholdKeygenConfirm(p) => p.encode(e, &mut ()), ManageParams::Json(map) => { e.map(map.len() as u64)?; for (k, v) in map.iter() { @@ -183,6 +205,18 @@ pub(crate) fn manage_params_from(d: &mut Decoder<'_>) -> Result Ok(ManageParams::WebrtcIceCandidate( WebrtcIceCandidate::from_map(d)?, )), + ThresholdCommit::VERB => Ok(ManageParams::ThresholdCommit(ThresholdCommit::from_map(d)?)), + ThresholdSign::VERB => Ok(ManageParams::ThresholdSign(ThresholdSign::from_map(d)?)), + ThresholdAbort::VERB => Ok(ManageParams::ThresholdAbort(ThresholdAbort::from_map(d)?)), + ThresholdKeygenRound1::VERB => Ok(ManageParams::ThresholdKeygenRound1( + ThresholdKeygenRound1::from_map(d)?, + )), + ThresholdKeygenRound2::VERB => Ok(ManageParams::ThresholdKeygenRound2( + ThresholdKeygenRound2::from_map(d)?, + )), + ThresholdKeygenConfirm::VERB => Ok(ManageParams::ThresholdKeygenConfirm( + ThresholdKeygenConfirm::from_map(d)?, + )), _ => { let count = strict::definite_map(d)?; let mut map = CanonicalMap::new(); diff --git a/rust/crates/wire-mesh-wire/src/threshold.rs b/rust/crates/wire-mesh-wire/src/threshold.rs new file mode 100644 index 0000000..8582c77 --- /dev/null +++ b/rust/crates/wire-mesh-wire/src/threshold.rs @@ -0,0 +1,778 @@ +//! `threshold.cddl` — the `$manage-command-params` members for +//! `exadev.io/threshold` (wire-mesh#29/#171): the six manage-command verbs +//! covering FROST(Ed25519) threshold signing's two-round commit/sign +//! protocol, session abort, and the collapsed DKG/reshare +//! keygen-round1/round2/confirm triplet. Pure codec mirroring `webrtc.rs`'s +//! own pattern: nothing in this module performs any cryptography or +//! session orchestration, it only gets a verb's params on and off the wire +//! in the exact CDE byte layout `spec/threshold.cddl` and the generated TS +//! codec already agree on. + +use minicbor::{Decode, Decoder, Encode, Encoder}; + +use crate::error::DecodeError; +use crate::identity::{device_id_from, DeviceId}; +use crate::strict; + +/// `threshold-subject = { kind: tstr, protected: bstr, payload: bstr }`. +/// CDE key order: `kind` (4), `payload` (7), `protected` (9). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdSubject { + pub kind: String, + pub protected: Vec, + pub payload: Vec, +} + +impl Encode<()> for ThresholdSubject { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(3)?; + e.str("kind")?.str(&self.kind)?; + e.str("payload")?.bytes(&self.payload)?; + e.str("protected")?.bytes(&self.protected)?; + e.ok() + } +} + +impl Decode<'_, ()> for ThresholdSubject { + fn decode(d: &mut Decoder<'_>, _ctx: &mut ()) -> Result { + threshold_subject_from(d).map_err(minicbor::decode::Error::custom) + } +} + +pub(crate) fn threshold_subject_from(d: &mut Decoder<'_>) -> Result { + let mut map = strict::MapDecoder::new(d)?; + let mut kind: Option = None; + let mut protected: Option> = None; + let mut payload: Option> = None; + while let Some(key) = map.next_key(d)? { + match key { + "kind" => strict::set_once(&mut kind, strict::text_value(d)?)?, + "protected" => strict::set_once(&mut protected, strict::bytes_value(d)?)?, + "payload" => strict::set_once(&mut payload, strict::bytes_value(d)?)?, + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + } + Ok(ThresholdSubject { + kind: kind.ok_or(DecodeError::MissingField("kind"))?, + protected: protected.ok_or(DecodeError::MissingField("protected"))?, + payload: payload.ok_or(DecodeError::MissingField("payload"))?, + }) +} + +/// `threshold-commitment = { participant: device-id, hiding: bstr, binding: bstr }`. +/// CDE key order: `hiding` (6), `binding` (7), `participant` (11). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdCommitment { + pub participant: DeviceId, + pub hiding: Vec, + pub binding: Vec, +} + +impl Encode<()> for ThresholdCommitment { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(3)?; + e.str("hiding")?.bytes(&self.hiding)?; + e.str("binding")?.bytes(&self.binding)?; + e.str("participant")?; + self.participant.encode(e, ctx)?; + e.ok() + } +} + +impl Decode<'_, ()> for ThresholdCommitment { + fn decode(d: &mut Decoder<'_>, _ctx: &mut ()) -> Result { + threshold_commitment_from(d).map_err(minicbor::decode::Error::custom) + } +} + +pub(crate) fn threshold_commitment_from( + d: &mut Decoder<'_>, +) -> Result { + let mut map = strict::MapDecoder::new(d)?; + let mut participant: Option = None; + let mut hiding: Option> = None; + let mut binding: Option> = None; + while let Some(key) = map.next_key(d)? { + match key { + "participant" => strict::set_once(&mut participant, device_id_from(d)?)?, + "hiding" => strict::set_once(&mut hiding, strict::bytes_value(d)?)?, + "binding" => strict::set_once(&mut binding, strict::bytes_value(d)?)?, + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + } + Ok(ThresholdCommitment { + participant: participant.ok_or(DecodeError::MissingField("participant"))?, + hiding: hiding.ok_or(DecodeError::MissingField("hiding"))?, + binding: binding.ok_or(DecodeError::MissingField("binding"))?, + }) +} + +/// `threshold-commit = { verb: "threshold.commit", "session-id": session-id, +/// group: device-id, subject: threshold-subject, deadline: uint }`. +/// CDE key order: `verb` (4), `group` (5), `subject` (7), `deadline` (8), +/// `session-id` (10). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdCommit { + pub session_id: u64, + pub group: DeviceId, + pub subject: ThresholdSubject, + pub deadline: u64, +} + +impl ThresholdCommit { + pub const VERB: &'static str = "threshold.commit"; +} + +impl Encode<()> for ThresholdCommit { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(5)?; + e.str("verb")?.str(Self::VERB)?; + e.str("group")?; + self.group.encode(e, ctx)?; + e.str("subject")?; + self.subject.encode(e, ctx)?; + e.str("deadline")?.u64(self.deadline)?; + e.str("session-id")?.u64(self.session_id)?; + e.ok() + } +} + +impl ThresholdCommit { + pub(crate) fn from_map(d: &mut Decoder<'_>) -> Result { + let mut session_id: Option = None; + let mut group: Option = None; + let mut subject: Option = None; + let mut deadline: Option = None; + param::decode_params_map(d, ThresholdCommit::VERB, &mut |d, key| { + match key { + "session-id" => strict::set_once(&mut session_id, strict::uint_value(d)?)?, + "group" => strict::set_once(&mut group, device_id_from(d)?)?, + "subject" => strict::set_once(&mut subject, threshold_subject_from(d)?)?, + "deadline" => strict::set_once(&mut deadline, strict::uint_value(d)?)?, + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + Ok(()) + })?; + Ok(ThresholdCommit { + session_id: session_id.ok_or(DecodeError::MissingField("session-id"))?, + group: group.ok_or(DecodeError::MissingField("group"))?, + subject: subject.ok_or(DecodeError::MissingField("subject"))?, + deadline: deadline.ok_or(DecodeError::MissingField("deadline"))?, + }) + } +} + +/// `threshold-sign = { verb: "threshold.sign", "session-id": session-id, +/// commitments: [* threshold-commitment] }`. CDE key order: `verb` (4), +/// `session-id` (10), `commitments` (11). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdSign { + pub session_id: u64, + pub commitments: Vec, +} + +impl ThresholdSign { + pub const VERB: &'static str = "threshold.sign"; +} + +impl Encode<()> for ThresholdSign { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(3)?; + e.str("verb")?.str(Self::VERB)?; + e.str("session-id")?.u64(self.session_id)?; + e.str("commitments")?.array(self.commitments.len() as u64)?; + for commitment in &self.commitments { + commitment.encode(e, ctx)?; + } + e.ok() + } +} + +impl ThresholdSign { + pub(crate) fn from_map(d: &mut Decoder<'_>) -> Result { + let mut session_id: Option = None; + let mut commitments: Option> = None; + param::decode_params_map(d, ThresholdSign::VERB, &mut |d, key| { + match key { + "session-id" => strict::set_once(&mut session_id, strict::uint_value(d)?)?, + "commitments" => { + let count = strict::definite_array(d)?; + let mut list = Vec::new(); + for _ in 0..count { + list.push(threshold_commitment_from(d)?); + } + strict::set_once(&mut commitments, list)? + } + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + Ok(()) + })?; + Ok(ThresholdSign { + session_id: session_id.ok_or(DecodeError::MissingField("session-id"))?, + commitments: commitments.ok_or(DecodeError::MissingField("commitments"))?, + }) + } +} + +/// `threshold-abort = { verb: "threshold.abort", "session-id": session-id, +/// ? reason: tstr }`. CDE key order: `verb` (4), `reason` (6, optional), +/// `session-id` (10). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdAbort { + pub session_id: u64, + pub reason: Option, +} + +impl ThresholdAbort { + pub const VERB: &'static str = "threshold.abort"; +} + +impl Encode<()> for ThresholdAbort { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + let len = 2 + usize::from(self.reason.is_some()); + e.map(len as u64)?; + e.str("verb")?.str(Self::VERB)?; + if let Some(reason) = &self.reason { + e.str("reason")?.str(reason)?; + } + e.str("session-id")?.u64(self.session_id)?; + e.ok() + } +} + +impl ThresholdAbort { + pub(crate) fn from_map(d: &mut Decoder<'_>) -> Result { + let mut session_id: Option = None; + let mut reason: Option = None; + param::decode_params_map(d, ThresholdAbort::VERB, &mut |d, key| { + match key { + "session-id" => strict::set_once(&mut session_id, strict::uint_value(d)?)?, + "reason" => strict::set_once(&mut reason, strict::text_value(d)?)?, + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + Ok(()) + })?; + Ok(ThresholdAbort { + session_id: session_id.ok_or(DecodeError::MissingField("session-id"))?, + reason, + }) + } +} + +/// `threshold-keygen-round1 = { verb: "threshold.keygen-round1", +/// "session-id": session-id, threshold: uint, participants: [* device-id], +/// commitment: [* bstr], ? "proof-of-knowledge": bstr, +/// ? "existing-group-key": bstr }`. CDE key order: `verb` (4), `threshold` +/// (9), `commitment` (10), `session-id` (10; bytewise after `commitment`), +/// `participants` (12), `existing-group-key` (18), `proof-of-knowledge` +/// (18; bytewise after `existing-group-key`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdKeygenRound1 { + pub session_id: u64, + pub threshold: u64, + pub participants: Vec, + pub commitment: Vec>, + pub proof_of_knowledge: Option>, + pub existing_group_key: Option>, +} + +impl ThresholdKeygenRound1 { + pub const VERB: &'static str = "threshold.keygen-round1"; +} + +impl Encode<()> for ThresholdKeygenRound1 { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + let len = 5 + + usize::from(self.proof_of_knowledge.is_some()) + + usize::from(self.existing_group_key.is_some()); + e.map(len as u64)?; + e.str("verb")?.str(Self::VERB)?; + e.str("threshold")?.u64(self.threshold)?; + e.str("commitment")?.array(self.commitment.len() as u64)?; + for coefficient in &self.commitment { + e.bytes(coefficient)?; + } + e.str("session-id")?.u64(self.session_id)?; + e.str("participants")? + .array(self.participants.len() as u64)?; + for participant in &self.participants { + participant.encode(e, ctx)?; + } + if let Some(existing_group_key) = &self.existing_group_key { + e.str("existing-group-key")?.bytes(existing_group_key)?; + } + if let Some(proof_of_knowledge) = &self.proof_of_knowledge { + e.str("proof-of-knowledge")?.bytes(proof_of_knowledge)?; + } + e.ok() + } +} + +impl ThresholdKeygenRound1 { + pub(crate) fn from_map(d: &mut Decoder<'_>) -> Result { + let mut session_id: Option = None; + let mut threshold: Option = None; + let mut participants: Option> = None; + let mut commitment: Option>> = None; + let mut proof_of_knowledge: Option> = None; + let mut existing_group_key: Option> = None; + param::decode_params_map(d, ThresholdKeygenRound1::VERB, &mut |d, key| { + match key { + "session-id" => strict::set_once(&mut session_id, strict::uint_value(d)?)?, + "threshold" => strict::set_once(&mut threshold, strict::uint_value(d)?)?, + "participants" => { + let count = strict::definite_array(d)?; + let mut list = Vec::new(); + for _ in 0..count { + list.push(device_id_from(d)?); + } + strict::set_once(&mut participants, list)? + } + "commitment" => { + let count = strict::definite_array(d)?; + let mut list = Vec::new(); + for _ in 0..count { + list.push(strict::bytes_value(d)?); + } + strict::set_once(&mut commitment, list)? + } + "proof-of-knowledge" => { + strict::set_once(&mut proof_of_knowledge, strict::bytes_value(d)?)? + } + "existing-group-key" => { + strict::set_once(&mut existing_group_key, strict::bytes_value(d)?)? + } + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + Ok(()) + })?; + Ok(ThresholdKeygenRound1 { + session_id: session_id.ok_or(DecodeError::MissingField("session-id"))?, + threshold: threshold.ok_or(DecodeError::MissingField("threshold"))?, + participants: participants.ok_or(DecodeError::MissingField("participants"))?, + commitment: commitment.ok_or(DecodeError::MissingField("commitment"))?, + proof_of_knowledge, + existing_group_key, + }) + } +} + +/// `threshold-keygen-round2 = { verb: "threshold.keygen-round2", +/// "session-id": session-id, share: bstr }`. CDE key order: `verb` (4), +/// `share` (5), `session-id` (10). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdKeygenRound2 { + pub session_id: u64, + pub share: Vec, +} + +impl ThresholdKeygenRound2 { + pub const VERB: &'static str = "threshold.keygen-round2"; +} + +impl Encode<()> for ThresholdKeygenRound2 { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(3)?; + e.str("verb")?.str(Self::VERB)?; + e.str("share")?.bytes(&self.share)?; + e.str("session-id")?.u64(self.session_id)?; + e.ok() + } +} + +impl ThresholdKeygenRound2 { + pub(crate) fn from_map(d: &mut Decoder<'_>) -> Result { + let mut session_id: Option = None; + let mut share: Option> = None; + param::decode_params_map(d, ThresholdKeygenRound2::VERB, &mut |d, key| { + match key { + "session-id" => strict::set_once(&mut session_id, strict::uint_value(d)?)?, + "share" => strict::set_once(&mut share, strict::bytes_value(d)?)?, + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + Ok(()) + })?; + Ok(ThresholdKeygenRound2 { + session_id: session_id.ok_or(DecodeError::MissingField("session-id"))?, + share: share.ok_or(DecodeError::MissingField("share"))?, + }) + } +} + +/// `threshold-keygen-confirm = { verb: "threshold.keygen-confirm", +/// "session-id": session-id, "transcript-digest": bstr, "group-key": bstr }`. +/// CDE key order: `verb` (4), `group-key` (9), `session-id` (10), +/// `transcript-digest` (17). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdKeygenConfirm { + pub session_id: u64, + pub transcript_digest: Vec, + pub group_key: Vec, +} + +impl ThresholdKeygenConfirm { + pub const VERB: &'static str = "threshold.keygen-confirm"; +} + +impl Encode<()> for ThresholdKeygenConfirm { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.map(4)?; + e.str("verb")?.str(Self::VERB)?; + e.str("group-key")?.bytes(&self.group_key)?; + e.str("session-id")?.u64(self.session_id)?; + e.str("transcript-digest")?.bytes(&self.transcript_digest)?; + e.ok() + } +} + +impl ThresholdKeygenConfirm { + pub(crate) fn from_map(d: &mut Decoder<'_>) -> Result { + let mut session_id: Option = None; + let mut transcript_digest: Option> = None; + let mut group_key: Option> = None; + param::decode_params_map(d, ThresholdKeygenConfirm::VERB, &mut |d, key| { + match key { + "session-id" => strict::set_once(&mut session_id, strict::uint_value(d)?)?, + "transcript-digest" => { + strict::set_once(&mut transcript_digest, strict::bytes_value(d)?)? + } + "group-key" => strict::set_once(&mut group_key, strict::bytes_value(d)?)?, + other => return Err(DecodeError::UnknownKey(other.to_owned())), + } + Ok(()) + })?; + Ok(ThresholdKeygenConfirm { + session_id: session_id.ok_or(DecodeError::MissingField("session-id"))?, + transcript_digest: transcript_digest + .ok_or(DecodeError::MissingField("transcript-digest"))?, + group_key: group_key.ok_or(DecodeError::MissingField("group-key"))?, + }) + } +} + +/// Shared decode helper for the `$manage-command-params` members defined in +/// this module, mirroring `webrtc`'s and `exec`'s own private `param` +/// submodule. +pub(crate) mod param { + use super::*; + + pub(super) fn decode_params_map<'b>( + d: &mut Decoder<'b>, + expected_verb: &'static str, + consume: &mut dyn FnMut(&mut Decoder<'b>, &str) -> Result<(), DecodeError>, + ) -> Result<(), DecodeError> { + let mut map = strict::MapDecoder::new(d)?; + let mut saw_verb = false; + while let Some(key) = map.next_key(d)? { + if key == "verb" { + strict::literal(d, expected_verb)?; + saw_verb = true; + } else { + consume(d, key)?; + } + } + if !saw_verb { + return Err(DecodeError::MissingField("verb")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key_order_positions(bytes: &[u8], keys: &[&str]) -> Vec { + keys.iter() + .map(|k| { + bytes + .windows(k.len()) + .position(|w| w == k.as_bytes()) + .unwrap_or_else(|| panic!("key {k:?} present")) + }) + .collect() + } + + fn assert_sorted(bytes: &[u8], keys: &[&str]) { + let order = key_order_positions(bytes, keys); + let mut sorted = order.clone(); + sorted.sort_unstable(); + assert_eq!(order, sorted, "keys {keys:?} not in CDE order"); + } + + fn device_id(byte: u8) -> DeviceId { + DeviceId([byte; 32]) + } + + #[test] + fn threshold_subject_round_trip_and_key_order() { + let subject = ThresholdSubject { + kind: "capability-token".to_owned(), + protected: vec![1, 2, 3], + payload: vec![4, 5, 6], + }; + let bytes = minicbor::to_vec(&subject).expect("encode"); + let back: ThresholdSubject = minicbor::decode(&bytes).expect("decode"); + assert_eq!(back, subject); + assert_sorted(&bytes, &["kind", "payload", "protected"]); + } + + #[test] + fn threshold_commitment_round_trip_and_key_order() { + let commitment = ThresholdCommitment { + participant: device_id(1), + hiding: vec![1], + binding: vec![2], + }; + let bytes = minicbor::to_vec(&commitment).expect("encode"); + let back: ThresholdCommitment = minicbor::decode(&bytes).expect("decode"); + assert_eq!(back, commitment); + assert_sorted(&bytes, &["hiding", "binding", "participant"]); + } + + #[test] + fn threshold_commit_round_trip_and_key_order() { + let commit = ThresholdCommit { + session_id: 7, + group: device_id(9), + subject: ThresholdSubject { + kind: "room-notice".to_owned(), + protected: vec![1], + payload: vec![2], + }, + deadline: 1_700_000_000_000, + }; + let bytes = minicbor::to_vec(&commit).expect("encode"); + let back = ThresholdCommit::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, commit); + assert_sorted( + &bytes, + &["verb", "group", "subject", "deadline", "session-id"], + ); + } + + #[test] + fn threshold_sign_round_trip_with_multiple_commitments() { + let sign = ThresholdSign { + session_id: 3, + commitments: vec![ + ThresholdCommitment { + participant: device_id(1), + hiding: vec![1], + binding: vec![2], + }, + ThresholdCommitment { + participant: device_id(2), + hiding: vec![3], + binding: vec![4], + }, + ], + }; + let bytes = minicbor::to_vec(&sign).expect("encode"); + let back = ThresholdSign::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, sign); + assert_sorted(&bytes, &["verb", "session-id", "commitments"]); + } + + #[test] + fn threshold_sign_rejects_a_non_array_commitments_field() { + // A definite map { "verb": "threshold.sign", "session-id": 1, "commitments": 0 } -- commitments as a uint instead of an array. + let mut bytes: Vec = vec![0xa3]; + bytes.extend_from_slice(&minicbor::to_vec("verb").unwrap()); + bytes.extend_from_slice(&minicbor::to_vec("threshold.sign").unwrap()); + bytes.extend_from_slice(&minicbor::to_vec("commitments").unwrap()); + bytes.push(0x00); + bytes.extend_from_slice(&minicbor::to_vec("session-id").unwrap()); + bytes.push(0x01); + assert!(ThresholdSign::from_map(&mut Decoder::new(&bytes)).is_err()); + } + + #[test] + fn threshold_abort_round_trips_with_and_without_reason() { + let with_reason = ThresholdAbort { + session_id: 5, + reason: Some("participant unavailable".to_owned()), + }; + let bytes = minicbor::to_vec(&with_reason).expect("encode"); + let back = ThresholdAbort::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, with_reason); + assert_sorted(&bytes, &["verb", "reason", "session-id"]); + + let without_reason = ThresholdAbort { + session_id: 5, + reason: None, + }; + let bytes = minicbor::to_vec(&without_reason).expect("encode"); + assert_eq!(bytes[0], 0xa2); + let back = ThresholdAbort::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, without_reason); + } + + #[test] + fn threshold_keygen_round1_round_trips_fresh_dkg_shape() { + // Fresh DKG: proof-of-knowledge present, existing-group-key absent. + let round1 = ThresholdKeygenRound1 { + session_id: 1, + threshold: 2, + participants: vec![device_id(1), device_id(2), device_id(3)], + commitment: vec![vec![1, 2], vec![3, 4]], + proof_of_knowledge: Some(vec![9, 9]), + existing_group_key: None, + }; + let bytes = minicbor::to_vec(&round1).expect("encode"); + let back = ThresholdKeygenRound1::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, round1); + assert_sorted( + &bytes, + &[ + "verb", + "threshold", + "commitment", + "session-id", + "participants", + "proof-of-knowledge", + ], + ); + } + + #[test] + fn threshold_keygen_round1_round_trips_reshare_shape() { + // Reshare: existing-group-key present, proof-of-knowledge absent. + let round1 = ThresholdKeygenRound1 { + session_id: 2, + threshold: 2, + participants: vec![device_id(1), device_id(2)], + commitment: vec![vec![1]], + proof_of_knowledge: None, + existing_group_key: Some(vec![7; 32]), + }; + let bytes = minicbor::to_vec(&round1).expect("encode"); + let back = ThresholdKeygenRound1::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, round1); + assert_sorted( + &bytes, + &[ + "verb", + "threshold", + "commitment", + "session-id", + "participants", + "existing-group-key", + ], + ); + } + + #[test] + fn threshold_keygen_round1_round_trips_with_both_optionals_present() { + let round1 = ThresholdKeygenRound1 { + session_id: 2, + threshold: 2, + participants: vec![device_id(1)], + commitment: vec![vec![1]], + proof_of_knowledge: Some(vec![1]), + existing_group_key: Some(vec![2]), + }; + let bytes = minicbor::to_vec(&round1).expect("encode"); + assert_eq!(bytes[0], 0xa7); + let back = ThresholdKeygenRound1::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, round1); + assert_sorted( + &bytes, + &[ + "verb", + "threshold", + "commitment", + "session-id", + "participants", + "existing-group-key", + "proof-of-knowledge", + ], + ); + } + + #[test] + fn threshold_keygen_round2_round_trip() { + let round2 = ThresholdKeygenRound2 { + session_id: 4, + share: vec![1, 2, 3, 4], + }; + let bytes = minicbor::to_vec(&round2).expect("encode"); + let back = ThresholdKeygenRound2::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, round2); + assert_sorted(&bytes, &["verb", "share", "session-id"]); + } + + #[test] + fn threshold_keygen_confirm_round_trip() { + let confirm = ThresholdKeygenConfirm { + session_id: 6, + transcript_digest: vec![0xaa; 32], + group_key: vec![0xbb; 32], + }; + let bytes = minicbor::to_vec(&confirm).expect("encode"); + let back = ThresholdKeygenConfirm::from_map(&mut Decoder::new(&bytes)).expect("decode"); + assert_eq!(back, confirm); + assert_sorted( + &bytes, + &["verb", "group-key", "session-id", "transcript-digest"], + ); + } + + #[test] + fn typed_params_reject_unknown_keys() { + // { "verb": "threshold.commit", "session-id": 1, "bogus": true } -- + // missing group/subject/deadline too, but the unknown key must + // surface regardless of what else is absent. + let mut bytes: Vec = vec![0xa3]; + bytes.extend_from_slice(&minicbor::to_vec("verb").unwrap()); + bytes.extend_from_slice(&minicbor::to_vec("threshold.commit").unwrap()); + bytes.extend_from_slice(&minicbor::to_vec("bogus").unwrap()); + bytes.push(0xf5); + bytes.extend_from_slice(&minicbor::to_vec("session-id").unwrap()); + bytes.push(0x01); + assert!(ThresholdCommit::from_map(&mut Decoder::new(&bytes)).is_err()); + } + + #[test] + fn wrong_verb_literal_is_rejected() { + let sign_bytes = minicbor::to_vec(&ThresholdSign { + session_id: 1, + commitments: vec![], + }) + .expect("encode"); + // Decoding a threshold-sign payload as ThresholdCommit must fail: the verb literal doesn't match. + assert!(ThresholdCommit::from_map(&mut Decoder::new(&sign_bytes)).is_err()); + } +} From ea988f281698d8ba7031df3fda4c0d5f9c374a37 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:49:54 +0100 Subject: [PATCH 02/15] test(conformance): add frames.v1.json vectors for the six threshold verbs 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. --- conformance/frames.v1.json | 319 +++++++++++++++++++++++++++++++++++++ conformance/generate.ts | 162 +++++++++++++++++++ 2 files changed, 481 insertions(+) diff --git a/conformance/frames.v1.json b/conformance/frames.v1.json index a76e10b..d5178a7 100644 --- a/conformance/frames.v1.json +++ b/conformance/frames.v1.json @@ -824,6 +824,325 @@ } }, "wire_hex": "a364747970656862756c6b2d656e64666469676573745820cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd6b7472616e736665722d696450aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "name": "manage_request_v1_threshold_commit", + "message": { + "type": "manage-request", + "request-id": 17, + "command": { + "verb": "exadev.io/threshold:sign", + "params": { + "verb": "threshold.commit", + "session-id": 1, + "group": { + "hex": "5555555555555555555555555555555555555555555555555555555555555555" + }, + "subject": { + "kind": "capability-token", + "protected": { + "hex": "a2613126613458205555555555555555555555555555555555555555555555555555555555555555" + }, + "payload": { + "hex": "a76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + "deadline": 1893456060000 + } + }, + "scope": { + "kind": "group" + }, + "token": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e6701" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "wire_hex": "a564747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757065746f6b656e845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059016ba86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67636f6d6d616e64a2647665726278186578616465762e696f2f7468726573686f6c643a7369676e66706172616d73a56476657262707468726573686f6c642e636f6d6d69746567726f757058205555555555555555555555555555555555555555555555555555555555555555677375626a656374a3646b696e64706361706162696c6974792d746f6b656e677061796c6f616459010fa76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb6970726f7465637465645828a261312661345820555555555555555555555555555555555555555555555555555555555555555568646561646c696e651b000001b8dac69e606a73657373696f6e2d6964016a726571756573742d696411" + }, + { + "name": "manage_response_v1_threshold_commit_ok", + "message": { + "type": "manage-response", + "request-id": 17, + "outcome": { + "result": "ok", + "participant": { + "hex": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "hiding": { + "hex": "aa11" + }, + "binding": { + "hex": "bb22" + } + } + }, + "wire_hex": "a364747970656f6d616e6167652d726573706f6e7365676f7574636f6d65a466686964696e6742aa1166726573756c74626f6b6762696e64696e6742bb226b7061727469636970616e74582022222222222222222222222222222222222222222222222222222222222222226a726571756573742d696411" + }, + { + "name": "manage_request_v1_threshold_sign", + "message": { + "type": "manage-request", + "request-id": 18, + "command": { + "verb": "exadev.io/threshold:sign", + "params": { + "verb": "threshold.sign", + "session-id": 1, + "commitments": [ + { + "participant": { + "hex": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "hiding": { + "hex": "aa11" + }, + "binding": { + "hex": "bb22" + } + }, + { + "participant": { + "hex": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "hiding": { + "hex": "aa33" + }, + "binding": { + "hex": "bb44" + } + } + ] + } + }, + "scope": { + "kind": "group" + }, + "token": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e6701" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "wire_hex": "a564747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757065746f6b656e845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059016ba86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67636f6d6d616e64a2647665726278186578616465762e696f2f7468726573686f6c643a7369676e66706172616d73a364766572626e7468726573686f6c642e7369676e6a73657373696f6e2d6964016b636f6d6d69746d656e747382a366686964696e6742aa116762696e64696e6742bb226b7061727469636970616e7458202222222222222222222222222222222222222222222222222222222222222222a366686964696e6742aa336762696e64696e6742bb446b7061727469636970616e74582033333333333333333333333333333333333333333333333333333333333333336a726571756573742d696412" + }, + { + "name": "manage_response_v1_threshold_sign_ok", + "message": { + "type": "manage-response", + "request-id": 18, + "outcome": { + "result": "ok", + "share": { + "hex": "845828a2613127613458202222222222222222222222222222222222222222222222222222222222222222a058a5a56567726f75705820555555555555555555555555555555555555555555555555555555555555555565736861726542ee0166697373756572582022222222222222222222222222222222222222222222222222222222222222226a6973737565722d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee6a73657373696f6e2d6964015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + "wire_hex": "a364747970656f6d616e6167652d726573706f6e7365676f7574636f6d65a2657368617265590115845828a2613127613458202222222222222222222222222222222222222222222222222222222222222222a058a5a56567726f75705820555555555555555555555555555555555555555555555555555555555555555565736861726542ee0166697373756572582022222222222222222222222222222222222222222222222222222222222222226a6973737565722d6b6579a263616c67276a7075626c69632d6b65795820eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee6a73657373696f6e2d6964015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff66726573756c74626f6b6a726571756573742d696412" + }, + { + "name": "manage_request_v1_threshold_abort_with_reason", + "message": { + "type": "manage-request", + "request-id": 19, + "command": { + "verb": "exadev.io/threshold:sign", + "params": { + "verb": "threshold.abort", + "session-id": 1, + "reason": "participant unavailable before the deadline" + } + }, + "scope": { + "kind": "group" + }, + "token": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e6701" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "wire_hex": "a564747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757065746f6b656e845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059016ba86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67636f6d6d616e64a2647665726278186578616465762e696f2f7468726573686f6c643a7369676e66706172616d73a364766572626f7468726573686f6c642e61626f727466726561736f6e782b7061727469636970616e7420756e617661696c61626c65206265666f72652074686520646561646c696e656a73657373696f6e2d6964016a726571756573742d696413" + }, + { + "name": "manage_request_v1_threshold_abort_without_reason", + "message": { + "type": "manage-request", + "request-id": 20, + "command": { + "verb": "exadev.io/threshold:sign", + "params": { + "verb": "threshold.abort", + "session-id": 1 + } + }, + "scope": { + "kind": "group" + }, + "token": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e6701" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "wire_hex": "a564747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757065746f6b656e845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059016ba86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67636f6d6d616e64a2647665726278186578616465762e696f2f7468726573686f6c643a7369676e66706172616d73a264766572626f7468726573686f6c642e61626f72746a73657373696f6e2d6964016a726571756573742d696414" + }, + { + "name": "manage_request_v1_threshold_keygen_round1_fresh_dkg", + "message": { + "type": "manage-request", + "request-id": 21, + "command": { + "verb": "exadev.io/threshold:keygen", + "params": { + "verb": "threshold.keygen-round1", + "session-id": 2, + "threshold": 2, + "participants": [ + { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "hex": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "hex": "3333333333333333333333333333333333333333333333333333333333333333" + } + ], + "commitment": [ + { + "hex": "c001" + }, + { + "hex": "c002" + } + ], + "proof-of-knowledge": { + "hex": "a0f0" + } + } + }, + "scope": { + "kind": "group" + } + }, + "wire_hex": "a464747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757067636f6d6d616e64a26476657262781a6578616465762e696f2f7468726573686f6c643a6b657967656e66706172616d73a66476657262777468726573686f6c642e6b657967656e2d726f756e6431697468726573686f6c64026a636f6d6d69746d656e748242c00142c0026a73657373696f6e2d6964026c7061727469636970616e7473835820111111111111111111111111111111111111111111111111111111111111111158202222222222222222222222222222222222222222222222222222222222222222582033333333333333333333333333333333333333333333333333333333333333337270726f6f662d6f662d6b6e6f776c6564676542a0f06a726571756573742d696415" + }, + { + "name": "manage_request_v1_threshold_keygen_round1_reshare", + "message": { + "type": "manage-request", + "request-id": 22, + "command": { + "verb": "exadev.io/threshold:reshare", + "params": { + "verb": "threshold.keygen-round1", + "session-id": 3, + "threshold": 2, + "participants": [ + { + "hex": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "hex": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "hex": "3333333333333333333333333333333333333333333333333333333333333333" + }, + { + "hex": "4444444444444444444444444444444444444444444444444444444444444444" + } + ], + "commitment": [ + { + "hex": "c003" + } + ], + "existing-group-key": { + "hex": "6666666666666666666666666666666666666666666666666666666666666666" + } + } + }, + "scope": { + "kind": "group" + } + }, + "wire_hex": "a464747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757067636f6d6d616e64a26476657262781b6578616465762e696f2f7468726573686f6c643a7265736861726566706172616d73a66476657262777468726573686f6c642e6b657967656e2d726f756e6431697468726573686f6c64026a636f6d6d69746d656e748142c0036a73657373696f6e2d6964036c7061727469636970616e74738458201111111111111111111111111111111111111111111111111111111111111111582022222222222222222222222222222222222222222222222222222222222222225820333333333333333333333333333333333333333333333333333333333333333358204444444444444444444444444444444444444444444444444444444444444444726578697374696e672d67726f75702d6b6579582066666666666666666666666666666666666666666666666666666666666666666a726571756573742d696416" + }, + { + "name": "manage_request_v1_threshold_keygen_round2", + "message": { + "type": "manage-request", + "request-id": 23, + "command": { + "verb": "exadev.io/threshold:keygen", + "params": { + "verb": "threshold.keygen-round2", + "session-id": 2, + "share": { + "hex": "5ba2e0" + } + } + }, + "scope": { + "kind": "group" + } + }, + "wire_hex": "a464747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757067636f6d6d616e64a26476657262781a6578616465762e696f2f7468726573686f6c643a6b657967656e66706172616d73a36476657262777468726573686f6c642e6b657967656e2d726f756e6432657368617265435ba2e06a73657373696f6e2d6964026a726571756573742d696417" + }, + { + "name": "manage_request_v1_threshold_keygen_confirm", + "message": { + "type": "manage-request", + "request-id": 24, + "command": { + "verb": "exadev.io/threshold:keygen", + "params": { + "verb": "threshold.keygen-confirm", + "session-id": 2, + "transcript-digest": { + "hex": "7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d" + }, + "group-key": { + "hex": "6666666666666666666666666666666666666666666666666666666666666666" + } + } + }, + "scope": { + "kind": "group" + } + }, + "wire_hex": "a464747970656e6d616e6167652d726571756573746573636f7065a1646b696e646567726f757067636f6d6d616e64a26476657262781a6578616465762e696f2f7468726573686f6c643a6b657967656e66706172616d73a4647665726278187468726573686f6c642e6b657967656e2d636f6e6669726d6967726f75702d6b6579582066666666666666666666666666666666666666666666666666666666666666666a73657373696f6e2d696402717472616e7363726970742d64696765737458207d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d6a726571756573742d69641818" } ] } diff --git a/conformance/generate.ts b/conformance/generate.ts index 85cd2d0..7a8f395 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -34,6 +34,8 @@ const deviceA = hex("11".repeat(SHA256_BYTE_LENGTH)); // issuer / coordinator const deviceB = hex("22".repeat(SHA256_BYTE_LENGTH)); // bearer of the root token / delegator const deviceC = hex("33".repeat(SHA256_BYTE_LENGTH)); // bearer of the delegated token const deviceD = hex("44".repeat(SHA256_BYTE_LENGTH)); // handle-record subject +const deviceGroup = hex("55".repeat(SHA256_BYTE_LENGTH)); // exadev.io/threshold's own group device-id -- SHA-256(group verifying key), an ordinary identity.cddl device-id derivation applied to a FROST-issued Ed25519 key, distinct from any single participant's own device-id +const deviceGroupKeyBytes = hex("66".repeat(ED25519_PUBLIC_KEY_BYTE_LENGTH)); // synthetic group verifying-key bytes, reused by threshold-keygen-round1's existing-group-key and threshold-keygen-confirm's group-key const publicKeyEs256A = hex( "04" + @@ -661,6 +663,166 @@ const frameVectors: Vector[] = [ "transfer-id": hex("aa".repeat(TOKEN_ID_BYTE_LENGTH)), digest: hex("cd".repeat(SHA256_BYTE_LENGTH)), // digest is a SHA-256 hash, the same 32-byte length device-id derivation already uses }), + // exadev.io/threshold (wire-mesh#29/#171) -- FROST(Ed25519) threshold signing's two-round commit/sign protocol, session abort, and the collapsed DKG/reshare keygen-round1/round2/confirm triplet. The group itself (deviceGroup) is a synthetic Ed25519 device-id distinct from deviceA/B/C, which here play the role of the group's own committing/signing participants. + vector("manage_request_v1_threshold_commit", { + type: "manage-request", + "request-id": 17, + command: { + verb: "exadev.io/threshold:sign", + params: { + verb: "threshold.commit", + "session-id": 1, + group: deviceGroup, + subject: { + kind: "capability-token", + protected: hex(wireHex({ 1: -7, 4: deviceGroup })), + payload: hex(wireHex(rootTokenClaims)), + }, + deadline: 1893456060000, + }, + }, + scope: { kind: "group" }, + token: roomMemberRootToken, + }), + // manage-ok extended per threshold-commit's own comment: "manage-ok extended with: participant: device-id, hiding: bstr, binding: bstr" -- returning a commitment IS the participant's act of authorisation. + vector("manage_response_v1_threshold_commit_ok", { + type: "manage-response", + "request-id": 17, + outcome: { + result: "ok", + participant: deviceB, + hiding: hex("aa11"), + binding: hex("bb22"), + }, + }), + vector("manage_request_v1_threshold_sign", { + type: "manage-request", + "request-id": 18, + command: { + verb: "exadev.io/threshold:sign", + params: { + verb: "threshold.sign", + "session-id": 1, + commitments: [ + { participant: deviceB, hiding: hex("aa11"), binding: hex("bb22") }, + { participant: deviceC, hiding: hex("aa33"), binding: hex("bb44") }, + ], + }, + }, + scope: { kind: "group" }, + token: roomMemberRootToken, + }), + // manage-ok extended per threshold-sign's own comment: "manage-ok extended with: share: bstr .cbor threshold-share-envelope" -- the released share, self-certifying under the releasing participant's own PERSONAL key (never the group's). + vector("manage_response_v1_threshold_sign_ok", { + type: "manage-response", + "request-id": 18, + outcome: { + result: "ok", + share: hex( + wireHex([ + hex(wireHex({ 1: -8, 4: deviceB })), + {}, + hex( + wireHex({ + "session-id": 1, + group: deviceGroup, + share: hex("ee01"), + issuer: deviceB, + "issuer-key": { alg: -8, "public-key": publicKeyEd25519D }, + }), + ), + signatureFiller, + ]), + ), + }, + }), + vector("manage_request_v1_threshold_abort_with_reason", { + type: "manage-request", + "request-id": 19, + command: { + verb: "exadev.io/threshold:sign", + params: { + verb: "threshold.abort", + "session-id": 1, + reason: "participant unavailable before the deadline", + }, + }, + scope: { kind: "group" }, + token: roomMemberRootToken, + }), + vector("manage_request_v1_threshold_abort_without_reason", { + type: "manage-request", + "request-id": 20, + command: { + verb: "exadev.io/threshold:sign", + params: { verb: "threshold.abort", "session-id": 1 }, + }, + scope: { kind: "group" }, + token: roomMemberRootToken, + }), + // Fresh DKG: existing-group-key absent, proof-of-knowledge REQUIRED and present. + vector("manage_request_v1_threshold_keygen_round1_fresh_dkg", { + type: "manage-request", + "request-id": 21, + command: { + verb: "exadev.io/threshold:keygen", + params: { + verb: "threshold.keygen-round1", + "session-id": 2, + threshold: 2, + participants: [deviceA, deviceB, deviceC], + commitment: [hex("c001"), hex("c002")], + "proof-of-knowledge": hex("a0f0"), + }, + }, + scope: { kind: "group" }, + }), + // Reshare: existing-group-key present (the group being reshared), proof-of-knowledge MAY be omitted -- see threshold.cddl's own comment on why the rogue-key attack doesn't apply here. + vector("manage_request_v1_threshold_keygen_round1_reshare", { + type: "manage-request", + "request-id": 22, + command: { + verb: "exadev.io/threshold:reshare", + params: { + verb: "threshold.keygen-round1", + "session-id": 3, + threshold: 2, + participants: [deviceA, deviceB, deviceC, deviceD], + commitment: [hex("c003")], + "existing-group-key": deviceGroupKeyBytes, + }, + }, + scope: { kind: "group" }, + }), + // Pairwise, confidential -- MUST travel only over an end-to-end-confidential connection (threshold.cddl's own comment). + vector("manage_request_v1_threshold_keygen_round2", { + type: "manage-request", + "request-id": 23, + command: { + verb: "exadev.io/threshold:keygen", + params: { + verb: "threshold.keygen-round2", + "session-id": 2, + share: hex("5ba2e0"), + }, + }, + scope: { kind: "group" }, + }), + // The mandatory echo-broadcast confirmation round: every participant exchanges a digest over the full ordered round-1 package set plus the derived group key. + vector("manage_request_v1_threshold_keygen_confirm", { + type: "manage-request", + "request-id": 24, + command: { + verb: "exadev.io/threshold:keygen", + params: { + verb: "threshold.keygen-confirm", + "session-id": 2, + "transcript-digest": hex("7d".repeat(SHA256_BYTE_LENGTH)), + "group-key": deviceGroupKeyBytes, + }, + }, + scope: { kind: "group" }, + }), ]; // ----------------------------------------------------------------------- From f31646f28b65d55486be1791ca98b435dd264370 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:54:29 +0100 Subject: [PATCH 03/15] feat(core): add the NonceStore port for durable FROST signing-nonce persistence 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. --- .../core/src/adapters/memory-nonce-store.ts | 34 ++++++++++++++++ ts/packages/core/src/ports/nonce-store.ts | 22 ++++++++++ .../core/test/memory-nonce-store.unit.test.ts | 40 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 ts/packages/core/src/adapters/memory-nonce-store.ts create mode 100644 ts/packages/core/src/ports/nonce-store.ts create mode 100644 ts/packages/core/test/memory-nonce-store.unit.test.ts diff --git a/ts/packages/core/src/adapters/memory-nonce-store.ts b/ts/packages/core/src/adapters/memory-nonce-store.ts new file mode 100644 index 0000000..f54cbb8 --- /dev/null +++ b/ts/packages/core/src/adapters/memory-nonce-store.ts @@ -0,0 +1,34 @@ +import { NonceStoreError, type NonceStore } from "../ports/nonce-store.js"; + +/** An in-process NonceStore for tests and single-process development only -- a Map guards a single take-then-remove critical section (JS's own single-threaded execution already gives it the atomicity `wire_mesh_threshold::nonce_store::InMemoryNonceStore`'s Mutex provides on the Rust side), but not durability: a process crash loses everything, so a real deployment MUST supply a persistent adapter (e.g. IndexedDB-backed) behind the same contract instead. */ +export function createMemoryNonceStore(): NonceStore { + const nonces = new Map(); + + return { + persist: async (sessionId, value) => { + if (nonces.has(sessionId)) { + throw new NonceStoreError( + `session-id ${sessionId.toString()} already has a persisted nonce pair`, + sessionId, + ); + } + nonces.set(sessionId, value); + return Promise.resolve(); + }, + take: async (sessionId) => { + const value = nonces.get(sessionId); + if (value === undefined) { + throw new NonceStoreError( + `no persisted, unused nonce pair for session-id ${sessionId.toString()}`, + sessionId, + ); + } + nonces.delete(sessionId); + return Promise.resolve(Uint8Array.from(value)); + }, + discard: async (sessionId) => { + nonces.delete(sessionId); + return Promise.resolve(); + }, + }; +} diff --git a/ts/packages/core/src/ports/nonce-store.ts b/ts/packages/core/src/ports/nonce-store.ts new file mode 100644 index 0000000..8154051 --- /dev/null +++ b/ts/packages/core/src/ports/nonce-store.ts @@ -0,0 +1,22 @@ +/** + * Durable per-session FROST signing-nonce persistence -- a storage-boundary port, not a concrete backend, mirroring `wire_mesh_threshold::nonce_store::NonceStore` on the Rust side exactly (same three operations, same one-shot `take` contract). Nonce reuse across two released signature shares is catastrophic: it discloses the participant's long-term key share outright, and T such disclosures reconstruct the group secret. `spec/threshold.cddl`'s own verifier obligations require a participant to durably persist its round-1 nonce pair BEFORE the round-1 response leaves the device, and to mark it used atomically BEFORE the round-2 response leaves the device -- this trait is the contract that obligation is checked against; concrete storage (IndexedDB, a file, `KeyValueStorage`-backed) lives in an adapter, never here. + */ +export interface NonceStore { + /** Durably persists `nonces` for `sessionId`, keyed so that at most one nonce pair ever exists per session. MUST complete before the caller's own round-1 response is allowed to leave the device -- an obligation this port cannot enforce structurally, only its caller can. Rejects if `sessionId` already has a persisted nonce pair. */ + persist: (sessionId: bigint, nonces: Uint8Array) => Promise; + /** Atomically retrieves and removes the nonce pair for `sessionId`. A second call for the same `sessionId` MUST reject -- this is what makes "release a second share for one nonce pair" structurally unrepresentable rather than merely discouraged. */ + take: (sessionId: bigint) => Promise>; + /** Discards an abandoned session's nonce without releasing a share -- the `deadline` expiry path and the `threshold.abort` path both call this, never `take`, since neither actually produces a signature. A no-op (never rejects) for an unknown or already-removed session-id. */ + discard: (sessionId: bigint) => Promise; +} + +/** Thrown by a `NonceStore` implementation's `persist` (session-id already has a nonce on record) or `take` (no persisted, unused nonce pair for this session-id -- never persisted, already expired, or a share was already released once). Callers MUST treat every `take` rejection identically regardless of which of those three caused it: refuse to sign, never re-derive or fabricate a substitute nonce. */ +export class NonceStoreError extends Error { + constructor( + message: string, + readonly sessionId: bigint, + ) { + super(message); + this.name = "NonceStoreError"; + } +} diff --git a/ts/packages/core/test/memory-nonce-store.unit.test.ts b/ts/packages/core/test/memory-nonce-store.unit.test.ts new file mode 100644 index 0000000..0bf28d5 --- /dev/null +++ b/ts/packages/core/test/memory-nonce-store.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryNonceStore } from "../src/adapters/memory-nonce-store.js"; +import { NonceStoreError } from "../src/ports/nonce-store.js"; + +describe("memory-nonce-store: createMemoryNonceStore", () => { + it("take is one-shot -- a second call for the same session rejects", async () => { + const store = createMemoryNonceStore(); + await store.persist(1n, new Uint8Array([1, 2, 3])); + await expect(store.take(1n)).resolves.toEqual(new Uint8Array([1, 2, 3])); + await expect(store.take(1n)).rejects.toThrow(NonceStoreError); + }); + + it("persist refuses to overwrite an existing session", async () => { + const store = createMemoryNonceStore(); + await store.persist(1n, new Uint8Array([1])); + await expect(store.persist(1n, new Uint8Array([2]))).rejects.toThrow( + NonceStoreError, + ); + }); + + it("discard makes a later take fail closed", async () => { + const store = createMemoryNonceStore(); + await store.persist(1n, new Uint8Array([9])); + await store.discard(1n); + await expect(store.take(1n)).rejects.toThrow(NonceStoreError); + }); + + it("discard of an unknown session is a no-op, never rejects", async () => { + const store = createMemoryNonceStore(); + await expect(store.discard(999n)).resolves.toBeUndefined(); + }); + + it("keeps sessions independent by session-id", async () => { + const store = createMemoryNonceStore(); + await store.persist(1n, new Uint8Array([1])); + await store.persist(2n, new Uint8Array([2])); + await expect(store.take(2n)).resolves.toEqual(new Uint8Array([2])); + await expect(store.take(1n)).resolves.toEqual(new Uint8Array([1])); + }); +}); From cc06785d1683c1557f537fe3a519a099b424322b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:54:29 +0100 Subject: [PATCH 04/15] feat(core): mint and verify threshold-share-envelope 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. --- .../src/domain/threshold-share-envelope.ts | 100 ++++++++++++++++++ .../threshold-share-envelope.unit.test.ts | 72 +++++++++++++ ts/packages/core/test/tokens-fixtures.ts | 13 +++ 3 files changed, 185 insertions(+) create mode 100644 ts/packages/core/src/domain/threshold-share-envelope.ts create mode 100644 ts/packages/core/test/threshold-share-envelope.unit.test.ts diff --git a/ts/packages/core/src/domain/threshold-share-envelope.ts b/ts/packages/core/src/domain/threshold-share-envelope.ts new file mode 100644 index 0000000..d810f96 --- /dev/null +++ b/ts/packages/core/src/domain/threshold-share-envelope.ts @@ -0,0 +1,100 @@ +/** + * `threshold-share-claims`/`threshold-share-envelope` -- a round-2 signature share, wrapped in its own additional `cose-sign1` signed under the releasing participant's own PERSONAL device key, never the group's. This is what makes misbehaviour publicly provable rather than merely locally identifiable to the coordinator: anyone holding this envelope, not just the coordinator that requested the share, can attribute a specific released share to a specific, identifiable device. Mirrors `wire_mesh_threshold::share_envelope` on the Rust side exactly, reusing this package's own `sig1ToBeSigned`/self-certification pattern (`domain/tokens.ts`'s `mintCapabilityToken`/`verifyCapabilityToken`) rather than a second hand-rolled construction. + */ +import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; +import { + thresholdShareClaimsSchema, + type DeviceId, + type ThresholdShareClaims, + type ThresholdShareEnvelope, +} from "../generated/protocol.js"; +import type { IdentityPort } from "../ports/identity.js"; +import { bytesEqual } from "./token-scope.js"; +import { sig1ToBeSigned } from "./tokens.js"; + +/** Normalises cbor2's encode() to a fresh, non-shared, whole-buffer Uint8Array -- what the generated schemas' concrete-typed fields require, matching tokens.ts's own private encodeBuf. */ +function encodeBuf(value: unknown): Uint8Array { + return Uint8Array.from(encode(value, cdeEncodeOptions)); +} + +/** The domain-level view of `threshold-share-claims`, with `session-id` widened to `bigint` (matching `ThresholdCoordinator`'s own convention -- see `adapters/threshold-identity.ts`) rather than the wire schema's plain `number`. */ +export interface ThresholdShareClaimsDomain { + sessionId: bigint; + group: DeviceId; + /** The raw FROST `round2::SignatureShare` bytes -- opaque at this layer. */ + share: Uint8Array; + /** The releasing participant's own PERSONAL device-id, never the group's. */ + issuer: DeviceId; +} + +/** + * Mints a `threshold-share-envelope`: signs `claims` under `personalIdentity`'s own key (never the group's) via the same RFC 9052 Sig_structure every other self-certifying structure in this codebase uses. + */ +export async function mintShareEnvelope( + personalIdentity: Readonly, + sessionId: bigint, + group: DeviceId, + share: Uint8Array, +): Promise { + const claims: ThresholdShareClaims = { + "session-id": Number(sessionId), + group, + share: Uint8Array.from(share), + issuer: personalIdentity.deviceId, + "issuer-key": personalIdentity.identityKey, + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({ + 1: personalIdentity.identityKey.alg, + 4: personalIdentity.deviceId, + }); + const signature = await personalIdentity.sign( + sig1ToBeSigned(protectedHeader, payload), + ); + return [protectedHeader, {}, payload, signature]; +} + +/** + * Verifies a `threshold-share-envelope` and returns its claims: checks self-certification (`sha256(issuer-key.public-key) == issuer`), then the signature against the embedded `issuer-key` -- the same two-step obligation every other self-certifying structure in this spec carries. Returns undefined for anything malformed or unverifiable, never throws -- hostile input produces a verdict, matching `verifyCapabilityToken`'s own convention. A caller that additionally requires the envelope to have come from one SPECIFIC expected participant checks the returned claims' `issuer` itself; this function's own job is only the envelope's internal consistency. + */ +export async function verifyShareEnvelope( + identity: Readonly, + envelope: Readonly, +): Promise { + const [protectedHeader, , payload, signature] = envelope; + if (payload === null) { + return undefined; + } + + let decoded: unknown; + try { + decoded = decode(payload, cdeDecodeOptions); + } catch { + return undefined; + } + const result = thresholdShareClaimsSchema.safeParse(decoded); + if (!result.success) { + return undefined; + } + const claims = result.data; + + const derivedIssuer = await identity.deriveDeviceId( + claims["issuer-key"]["public-key"], + ); + if (!bytesEqual(derivedIssuer, claims.issuer)) { + return undefined; + } + + const toBeSigned = sig1ToBeSigned(protectedHeader, payload); + const ok = await identity.verify(claims["issuer-key"], toBeSigned, signature); + if (!ok) { + return undefined; + } + + return { + sessionId: BigInt(claims["session-id"]), + group: claims.group, + share: Uint8Array.from(claims.share), + issuer: claims.issuer, + }; +} diff --git a/ts/packages/core/test/threshold-share-envelope.unit.test.ts b/ts/packages/core/test/threshold-share-envelope.unit.test.ts new file mode 100644 index 0000000..7434aa6 --- /dev/null +++ b/ts/packages/core/test/threshold-share-envelope.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { generateEd25519Identity } from "./tokens-fixtures.js"; +import { + mintShareEnvelope, + verifyShareEnvelope, +} from "../src/domain/threshold-share-envelope.js"; + +const DEVICE_ID_LENGTH = 32; + +function deviceId(byte: number): Uint8Array { + const bytes = new Uint8Array(DEVICE_ID_LENGTH); + bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; +} + +describe("threshold-share-envelope: mint/verify", () => { + it("mint then verify round-trips and the claims match", async () => { + const personal = await generateEd25519Identity(); + const group = deviceId(9); + const shareBytes = new Uint8Array([1, 2, 3, 4]); + + const envelope = await mintShareEnvelope(personal, 42n, group, shareBytes); + const claims = await verifyShareEnvelope(personal, envelope); + + expect(claims).toBeDefined(); + expect(claims?.sessionId).toBe(42n); + expect(claims?.group).toEqual(group); + expect(claims?.share).toEqual(shareBytes); + expect(claims?.issuer).toEqual(personal.deviceId); + }); + + it("verification only needs the embedded issuer-key, not the local verifier's own identity", async () => { + const signer = await generateEd25519Identity(); + const verifier = await generateEd25519Identity(); + const group = deviceId(1); + + const envelope = await mintShareEnvelope( + signer, + 7n, + group, + new Uint8Array([5, 6, 7]), + ); + + const claims = await verifyShareEnvelope(verifier, envelope); + expect(claims?.issuer).toEqual(signer.deviceId); + }); + + it("a tampered payload fails verification", async () => { + const personal = await generateEd25519Identity(); + const group = deviceId(9); + const envelope = await mintShareEnvelope( + personal, + 1n, + group, + new Uint8Array([1, 2, 3]), + ); + const tamperedPayload = + envelope[2] === null ? null : Uint8Array.from(envelope[2]); + if (tamperedPayload !== null) { + tamperedPayload[tamperedPayload.length - 1] ^= 0xff; + } + const tampered: typeof envelope = [ + envelope[0], + envelope[1], + tamperedPayload, + envelope[3], + ]; + + const claims = await verifyShareEnvelope(personal, tampered); + expect(claims).toBeUndefined(); + }); +}); diff --git a/ts/packages/core/test/tokens-fixtures.ts b/ts/packages/core/test/tokens-fixtures.ts index b56dfae..7685134 100644 --- a/ts/packages/core/test/tokens-fixtures.ts +++ b/ts/packages/core/test/tokens-fixtures.ts @@ -15,6 +15,7 @@ import type { import type { RevocationCheck } from "../src/domain/tokens.js"; export const ES256 = -7; +export const EDDSA = -8; export const HOUR_MS = 3_600_000; export const REVOKED_SHORTLY_BEFORE_NOW_MS = 1_000; // revoked-at sits just before `now` in these tests -- the value only needs to be in the past, not any particular distance export const P256_SIGNATURE_BYTE_LENGTH = 64; // raw ECDSA P-256 signature length @@ -53,6 +54,18 @@ export async function generateEs256Identity(): Promise { return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } +/** A fresh Ed25519 IdentityPort -- the personal-device signing key a threshold-share-envelope is minted under (never the group's own key), distinct from generateEs256Identity's P-256 identity. */ +export async function generateEd25519Identity(): Promise { + const keyPair = await webcrypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ]); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, EDDSA); +} + export function fixedClock(atMs: number): Clock { return { now: () => atMs }; } From c6854dc2062a35789abd3c50797f9dd252dc41c2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:55:38 +0100 Subject: [PATCH 05/15] feat(core): pure command builders and type guards for the six threshold 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. --- .../core/src/domain/threshold-network.ts | 192 ++++++++++++++++++ .../core/test/threshold-network.unit.test.ts | 125 ++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 ts/packages/core/src/domain/threshold-network.ts create mode 100644 ts/packages/core/test/threshold-network.unit.test.ts diff --git a/ts/packages/core/src/domain/threshold-network.ts b/ts/packages/core/src/domain/threshold-network.ts new file mode 100644 index 0000000..b1c17ee --- /dev/null +++ b/ts/packages/core/src/domain/threshold-network.ts @@ -0,0 +1,192 @@ +/** + * Pure, transport-free `exadev.io/threshold` wire logic (wire-mesh#171): building and parsing the six manage-commands `spec/threshold.cddl` defines (threshold.commit/.sign/.abort, threshold.keygen-round1/round2/confirm), mirroring `domain/webrtc-signaling.ts`'s own split between pure command construction and a transport-consuming adapter. Deliberately has no dependency on `MeshSession` or any coordinator/participant role logic, so both the coordinator-facing adapter (`adapters/threshold-network-coordinator.ts`) and the participant-facing responder (`adapters/threshold-participant.ts`) share exactly this one implementation of the wire-level protocol. + */ +import type { + CapabilityScope, + CapabilityVerb, + DeviceId, + ManageCommand, + ManageCommandParams, + ThresholdAbort, + ThresholdCommit, + ThresholdCommitment, + ThresholdKeygenConfirm, + ThresholdKeygenRound1, + ThresholdKeygenRound2, + ThresholdSign, +} from "../generated/protocol.js"; +import type { ThresholdSubject } from "./threshold-subject.js"; + +/** Gates threshold.commit/.sign/.abort -- the two-round signing protocol and its abort path. */ +export const THRESHOLD_SIGN_VERB: CapabilityVerb = "exadev.io/threshold:sign"; +/** Gates threshold.keygen-round1/round2/confirm when `existing-group-key` is absent on round1 -- a fresh DKG among devices that already trust each other. */ +export const THRESHOLD_KEYGEN_VERB: CapabilityVerb = "exadev.io/threshold:keygen"; +/** Gates the same keygen-round1/round2/confirm triplet when `existing-group-key` is present -- resharing can redefine the participant set entirely and is strictly more dangerous than an initial keygen, so it is a separately grantable and separately revocable capability (spec/threshold.cddl's own resolved design). */ +export const THRESHOLD_RESHARE_VERB: CapabilityVerb = "exadev.io/threshold:reshare"; + +/** No path: `group` names the specific group within the params themselves (threshold-commit/-sign) or is established by the ceremony's own participant set (keygen-round1/round2/confirm) -- there is no filesystem-subtree-style resource to scope by path. */ +export const THRESHOLD_GROUP_SCOPE: CapabilityScope = { kind: "group" }; + +/** Which capability verb gates one keygen-round1/round2/confirm ceremony, per spec/threshold.cddl's own verifier obligation: `exadev.io/threshold:reshare` when the ceremony carries an `existing-group-key` (round1), `exadev.io/threshold:keygen` otherwise. A caller drives one ceremony's round2/confirm messages under the SAME verb its own round1 used -- pass the same `isReshare` value to every builder for that session-id. */ +export function keygenCapabilityVerb(isReshare: boolean): CapabilityVerb { + return isReshare ? THRESHOLD_RESHARE_VERB : THRESHOLD_KEYGEN_VERB; +} + +function hasVerb(params: ManageCommandParams, verb: string): boolean { + return ( + typeof params === "object" && "verb" in params && params.verb === verb + ); +} + +export function isThresholdCommit( + params: ManageCommandParams, +): params is ThresholdCommit { + return hasVerb(params, "threshold.commit"); +} + +export function isThresholdSign( + params: ManageCommandParams, +): params is ThresholdSign { + return hasVerb(params, "threshold.sign"); +} + +export function isThresholdAbort( + params: ManageCommandParams, +): params is ThresholdAbort { + return hasVerb(params, "threshold.abort"); +} + +export function isThresholdKeygenRound1( + params: ManageCommandParams, +): params is ThresholdKeygenRound1 { + return hasVerb(params, "threshold.keygen-round1"); +} + +export function isThresholdKeygenRound2( + params: ManageCommandParams, +): params is ThresholdKeygenRound2 { + return hasVerb(params, "threshold.keygen-round2"); +} + +export function isThresholdKeygenConfirm( + params: ManageCommandParams, +): params is ThresholdKeygenConfirm { + return hasVerb(params, "threshold.keygen-confirm"); +} + +/** Round 1 of signing: sent to every prospective participant. Returning a commitment IS the participant's act of authorisation -- see threshold-subject's own comment. `sessionId` is a caller-chosen, per-ceremony correlation id (spec/CONVENTIONS.md), widened to `bigint` domain-side; the wire `session-id` field is a plain `uint`. */ +export function buildCommitCommand( + sessionId: bigint, + group: DeviceId, + subject: Readonly, + deadlineUnixMs: number, +): ManageCommand { + return { + verb: THRESHOLD_SIGN_VERB, + params: { + verb: "threshold.commit", + "session-id": Number(sessionId), + group, + subject, + deadline: deadlineUnixMs, + }, + }; +} + +/** Round 2 of signing: sent to every participant that committed, carrying every collected commitment so each participant re-derives the same SigningPackage. Deliberately carries no subject -- see threshold-sign's own CDDL comment. */ +export function buildSignCommand( + sessionId: bigint, + commitments: readonly ThresholdCommitment[], +): ManageCommand { + return { + verb: THRESHOLD_SIGN_VERB, + params: { + verb: "threshold.sign", + "session-id": Number(sessionId), + commitments: [...commitments], + }, + }; +} + +/** Aborts a signing, keygen, or reshare ceremony -- reusable across all three families, since a participant tracks which one a session-id belongs to from whichever message first introduced it. */ +export function buildAbortCommand( + sessionId: bigint, + reason?: string, +): ManageCommand { + return { + verb: THRESHOLD_SIGN_VERB, + params: { + verb: "threshold.abort", + "session-id": Number(sessionId), + ...(reason !== undefined ? { reason } : {}), + }, + }; +} + +export interface KeygenRound1Options { + /** REQUIRED for a fresh DKG (existingGroupKey absent) -- load-bearing, not ceremonial: without it a participant broadcasting last could adaptively bias the resulting group key (the rogue-key attack). MAY be omitted for a reshare. */ + proofOfKnowledge?: Uint8Array; + /** Present => this is a reshare of the named group's existing Ed25519 public key; absent => a fresh DKG. Determines this command's own capability verb via keygenCapabilityVerb. */ + existingGroupKey?: Uint8Array; +} + +/** Round 1 of DKG or reshare: broadcasts this participant's own Feldman VSS commitment (and, for a fresh DKG, its Schnorr proof of knowledge) to every other participant. The capability verb is derived from `options.existingGroupKey`'s presence via keygenCapabilityVerb -- callers driving the rest of this same ceremony (round2, confirm) must pass the identical fresh-vs-reshare choice to those builders. */ +export function buildKeygenRound1Command( + sessionId: bigint, + threshold: number, + participants: readonly DeviceId[], + commitment: readonly Uint8Array[], + options: Readonly = {}, +): ManageCommand { + const isReshare = options.existingGroupKey !== undefined; + return { + verb: keygenCapabilityVerb(isReshare), + params: { + verb: "threshold.keygen-round1", + "session-id": Number(sessionId), + threshold, + participants: [...participants], + commitment: [...commitment], + ...(options.proofOfKnowledge !== undefined + ? { "proof-of-knowledge": options.proofOfKnowledge } + : {}), + ...(options.existingGroupKey !== undefined + ? { "existing-group-key": options.existingGroupKey } + : {}), + }, + }; +} + +/** Round 2 of DKG or reshare: pairwise, confidential -- one message per recipient, carrying that recipient's own sub-share. MUST travel only over an end-to-end-confidential connection (spec/threshold.cddl's own comment). `isReshare` MUST match the value this ceremony's own round1 used. */ +export function buildKeygenRound2Command( + sessionId: bigint, + share: Uint8Array, + isReshare: boolean, +): ManageCommand { + return { + verb: keygenCapabilityVerb(isReshare), + params: { + verb: "threshold.keygen-round2", + "session-id": Number(sessionId), + share, + }, + }; +} + +/** The mandatory echo-broadcast confirmation round: every participant exchanges a digest over the full ordered round-1 package set plus the derived group key. `isReshare` MUST match the value this ceremony's own round1 used. */ +export function buildKeygenConfirmCommand( + sessionId: bigint, + transcriptDigest: Uint8Array, + groupKey: Uint8Array, + isReshare: boolean, +): ManageCommand { + return { + verb: keygenCapabilityVerb(isReshare), + params: { + verb: "threshold.keygen-confirm", + "session-id": Number(sessionId), + "transcript-digest": transcriptDigest, + "group-key": groupKey, + }, + }; +} diff --git a/ts/packages/core/test/threshold-network.unit.test.ts b/ts/packages/core/test/threshold-network.unit.test.ts new file mode 100644 index 0000000..5bfa8fc --- /dev/null +++ b/ts/packages/core/test/threshold-network.unit.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { DeviceId } from "../src/generated/protocol.js"; +import { + THRESHOLD_GROUP_SCOPE, + THRESHOLD_KEYGEN_VERB, + THRESHOLD_RESHARE_VERB, + THRESHOLD_SIGN_VERB, + buildAbortCommand, + buildCommitCommand, + buildKeygenConfirmCommand, + buildKeygenRound1Command, + buildKeygenRound2Command, + buildSignCommand, + isThresholdAbort, + isThresholdCommit, + isThresholdKeygenConfirm, + isThresholdKeygenRound1, + isThresholdKeygenRound2, + isThresholdSign, + keygenCapabilityVerb, +} from "../src/domain/threshold-network.js"; +import type { ThresholdSubject } from "../src/domain/threshold-subject.js"; + +const DEVICE_ID_LENGTH = 32; + +function deviceId(byte: number): DeviceId { + const bytes = new Uint8Array(DEVICE_ID_LENGTH); + bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; +} + +const subject: ThresholdSubject = { + kind: "capability-token", + protected: new Uint8Array([1]), + payload: new Uint8Array([2]), +}; + +describe("threshold-network: command builders and type guards", () => { + it("buildCommitCommand carries the group-scoped exadev.io/threshold:sign verb", () => { + const command = buildCommitCommand(1n, deviceId(9), subject, 1_000); + expect(command.verb).toBe(THRESHOLD_SIGN_VERB); + expect(isThresholdCommit(command.params)).toBe(true); + if (!isThresholdCommit(command.params)) { + throw new Error("expected threshold.commit params"); + } + expect(command.params["session-id"]).toBe(1); + expect(command.params.deadline).toBe(1_000); + expect(command.params.subject).toEqual(subject); + }); + + it("buildSignCommand round-trips its commitments", () => { + const commitments = [ + { participant: deviceId(1), hiding: new Uint8Array([1]), binding: new Uint8Array([2]) }, + ]; + const command = buildSignCommand(2n, commitments); + expect(isThresholdSign(command.params)).toBe(true); + if (!isThresholdSign(command.params)) { + throw new Error("expected threshold.sign params"); + } + expect(command.params.commitments).toEqual(commitments); + }); + + it("buildAbortCommand omits reason when not given, includes it when given", () => { + const withoutReason = buildAbortCommand(3n); + if (!isThresholdAbort(withoutReason.params)) { + throw new Error("expected threshold.abort params"); + } + expect(withoutReason.params.reason).toBeUndefined(); + + const withReason = buildAbortCommand(3n, "timed out"); + if (!isThresholdAbort(withReason.params)) { + throw new Error("expected threshold.abort params"); + } + expect(withReason.params.reason).toBe("timed out"); + }); + + it("keygenCapabilityVerb picks :keygen for fresh DKG and :reshare for a reshare", () => { + expect(keygenCapabilityVerb(false)).toBe(THRESHOLD_KEYGEN_VERB); + expect(keygenCapabilityVerb(true)).toBe(THRESHOLD_RESHARE_VERB); + }); + + it("buildKeygenRound1Command carries the fresh-DKG shape with proof-of-knowledge, no existing-group-key", () => { + const command = buildKeygenRound1Command(4n, 2, [deviceId(1), deviceId(2)], [new Uint8Array([1])], { + proofOfKnowledge: new Uint8Array([9]), + }); + expect(command.verb).toBe(THRESHOLD_KEYGEN_VERB); + if (!isThresholdKeygenRound1(command.params)) { + throw new Error("expected threshold.keygen-round1 params"); + } + expect(command.params["proof-of-knowledge"]).toEqual(new Uint8Array([9])); + expect(command.params["existing-group-key"]).toBeUndefined(); + }); + + it("buildKeygenRound1Command carries the reshare shape with existing-group-key, no proof-of-knowledge required", () => { + const groupKey = new Uint8Array(32).fill(7); + const command = buildKeygenRound1Command(5n, 2, [deviceId(1)], [new Uint8Array([1])], { + existingGroupKey: groupKey, + }); + expect(command.verb).toBe(THRESHOLD_RESHARE_VERB); + if (!isThresholdKeygenRound1(command.params)) { + throw new Error("expected threshold.keygen-round1 params"); + } + expect(command.params["existing-group-key"]).toEqual(groupKey); + expect(command.params["proof-of-knowledge"]).toBeUndefined(); + }); + + it("buildKeygenRound2Command and buildKeygenConfirmCommand use the given ceremony's own capability verb", () => { + const round2 = buildKeygenRound2Command(6n, new Uint8Array([1]), true); + expect(round2.verb).toBe(THRESHOLD_RESHARE_VERB); + expect(isThresholdKeygenRound2(round2.params)).toBe(true); + + const confirm = buildKeygenConfirmCommand( + 6n, + new Uint8Array([1]), + new Uint8Array([2]), + false, + ); + expect(confirm.verb).toBe(THRESHOLD_KEYGEN_VERB); + expect(isThresholdKeygenConfirm(confirm.params)).toBe(true); + }); + + it("THRESHOLD_GROUP_SCOPE has no path -- the whole-scope root", () => { + expect(THRESHOLD_GROUP_SCOPE).toEqual({ kind: "group" }); + }); +}); From bb2d49c03cfaeffc918498d264d4ea295b0f4783 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:01:07 +0100 Subject: [PATCH 06/15] feat(threshold): split/combine FROST signing commitments into hiding 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. --- .../wire-mesh-threshold-wasm/src/lib.rs | 43 ++++++++++++++ .../crates/wire-mesh-threshold/src/signing.rs | 58 +++++++++++++++++++ .../core/src/adapters/threshold-wasm.ts | 24 ++++++++ .../core/test/threshold-wasm.unit.test.ts | 15 +++++ 4 files changed, 140 insertions(+) diff --git a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs index 43013a9..ac2f74e 100644 --- a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs +++ b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs @@ -338,6 +338,49 @@ pub fn signing_round1_commit(own_key_package: Vec) -> Result, + binding: Vec, +} + +#[wasm_bindgen] +impl SplitCommitmentsOutput { + #[wasm_bindgen(getter)] + pub fn hiding(&self) -> Vec { + self.hiding.clone() + } + + #[wasm_bindgen(getter)] + pub fn binding(&self) -> Vec { + self.binding.clone() + } +} + +/// Splits a serialized `SigningCommitments` (`signing_round1_commit`'s own +/// `commitments` output) into the two independently-serialized halves +/// `threshold-commitment`'s wire shape carries (`hiding: bstr, binding: +/// bstr`) -- unlike frost-core's own combined-blob serialization, which is +/// opaque and not spec-shaped. The inverse of [`combine_commitments`]. +#[wasm_bindgen] +pub fn split_commitments(commitments: Vec) -> Result { + let commitments = SigningCommitments::deserialize(&commitments).map_err(js_err)?; + let (hiding, binding) = + wire_mesh_threshold::signing::split_commitments(&commitments).map_err(js_err)?; + Ok(SplitCommitmentsOutput { hiding, binding }) +} + +/// Reconstructs a serialized `SigningCommitments` (the same combined-blob +/// shape [`signing_build_package`] and [`signing_round2_sign`] expect) from +/// the two independently-serialized halves `threshold-commitment` carries +/// on the wire. The inverse of [`split_commitments`]. +#[wasm_bindgen] +pub fn combine_commitments(hiding: Vec, binding: Vec) -> Result, JsValue> { + let commitments = + wire_mesh_threshold::signing::combine_commitments(&hiding, &binding).map_err(js_err)?; + commitments.serialize().map_err(js_err) +} + /// Coordinator-side: builds the `SigningPackage` bytes every participant's /// round 2 is computed against. #[wasm_bindgen] diff --git a/rust/crates/wire-mesh-threshold/src/signing.rs b/rust/crates/wire-mesh-threshold/src/signing.rs index 2864cf2..bb19394 100644 --- a/rust/crates/wire-mesh-threshold/src/signing.rs +++ b/rust/crates/wire-mesh-threshold/src/signing.rs @@ -141,6 +141,39 @@ pub async fn round2_respond( Ok(envelope) } +/// Splits a `SigningCommitments` into its two independently-serialized +/// halves -- `threshold-commitment`'s own wire shape (`hiding: bstr, +/// binding: bstr`), unlike `SigningCommitments::serialize()`'s single +/// combined blob (frost-core's own internal format, opaque and not +/// spec-shaped). The inverse of [`combine_commitments`]. +pub fn split_commitments( + commitments: &SigningCommitments, +) -> Result<(Vec, Vec), SigningError> { + let hiding = commitments + .hiding() + .serialize() + .map_err(SigningError::from)?; + let binding = commitments + .binding() + .serialize() + .map_err(SigningError::from)?; + Ok((hiding, binding)) +} + +/// Reconstructs a `SigningCommitments` from the two independently- +/// serialized halves `threshold-commitment` carries on the wire. The +/// inverse of [`split_commitments`]. +pub fn combine_commitments( + hiding: &[u8], + binding: &[u8], +) -> Result { + let hiding = + frost_ed25519::round1::NonceCommitment::deserialize(hiding).map_err(SigningError::from)?; + let binding = + frost_ed25519::round1::NonceCommitment::deserialize(binding).map_err(SigningError::from)?; + Ok(SigningCommitments::new(hiding, binding)) +} + /// Coordinator-side: builds the `SigningPackage` round 2 is computed /// against, from the commitments collected in round 1 and the message /// every participant is expected to have independently reconstructed. @@ -542,4 +575,29 @@ mod tests { }) )); } + + /// `threshold-commitment`'s own wire shape carries hiding/binding as two + /// SEPARATE `bstr` fields, unlike `SigningCommitments::serialize()`'s + /// single combined blob (frost-core's own internal format) -- split then + /// combine must round-trip to the identical commitments a coordinator + /// builds its SigningPackage from. + #[test] + fn split_and_combine_commitments_round_trips() { + let (ids, key_packages, _public_key_package) = dkg_fixture(); + let id = *ids.first().expect("at least one participant"); + let kp = key_packages.get(&id).expect("key package"); + let store = InMemoryNonceStore::new(); + let commitments = round1_commit(&store, 1, kp).expect("round1_commit"); + + let (hiding, binding) = split_commitments(&commitments).expect("split"); + let recombined = combine_commitments(&hiding, &binding).expect("combine"); + assert_eq!(recombined, commitments); + } + + /// Malformed hiding/binding bytes must be rejected, never silently + /// accepted as some other valid-looking commitment. + #[test] + fn combine_commitments_rejects_malformed_bytes() { + assert!(combine_commitments(&[0u8; 4], &[0u8; 32]).is_err()); + } } diff --git a/ts/packages/core/src/adapters/threshold-wasm.ts b/ts/packages/core/src/adapters/threshold-wasm.ts index 7d10dbe..9e0107e 100644 --- a/ts/packages/core/src/adapters/threshold-wasm.ts +++ b/ts/packages/core/src/adapters/threshold-wasm.ts @@ -172,6 +172,30 @@ export function signingRound1Commit( }; } +export interface SplitCommitmentsResult { + hiding: Uint8Array; + binding: Uint8Array; +} + +/** Splits a serialized SigningCommitments blob (signingRound1Commit's own `commitments` output) into the two independently-serialized halves `threshold-commitment`'s wire shape carries (`hiding: bstr, binding: bstr`) -- unlike frost-core's own combined-blob serialization, which is opaque and not spec-shaped. The inverse of combineCommitments. */ +export function splitCommitments( + commitments: Uint8Array, +): SplitCommitmentsResult { + const out = wasm.split_commitments(commitments); + return { + hiding: toBufferSource(out.hiding), + binding: toBufferSource(out.binding), + }; +} + +/** Reconstructs a serialized SigningCommitments blob (the same combined-blob shape signingBuildPackage/signingRound2Sign expect) from the two independently-serialized halves `threshold-commitment` carries on the wire. The inverse of splitCommitments. */ +export function combineCommitments( + hiding: Uint8Array, + binding: Uint8Array, +): Uint8Array { + return toBufferSource(wasm.combine_commitments(hiding, binding)); +} + /** Coordinator-side: builds the signing-package bytes every participant's round 2 is computed against. */ export function signingBuildPackage( commitments: readonly DeviceKeyed[], diff --git a/ts/packages/core/test/threshold-wasm.unit.test.ts b/ts/packages/core/test/threshold-wasm.unit.test.ts index 0b14a51..dfdc2fa 100644 --- a/ts/packages/core/test/threshold-wasm.unit.test.ts +++ b/ts/packages/core/test/threshold-wasm.unit.test.ts @@ -15,6 +15,8 @@ import { signingBuildPackage, signingRound1Commit, signingRound2Sign, + splitCommitments, + combineCommitments, type DeviceKeyed, } from "../src/adapters/threshold-wasm.js"; import { @@ -225,6 +227,19 @@ describe("threshold-wasm: signing", () => { expect(ok).toBe(true); }); + it("splitCommitments/combineCommitments round-trips into the identical combined blob signingBuildPackage expects", () => { + const ids = [deviceId(1), deviceId(2), deviceId(THIRD_DEVICE_BYTE)]; + const [alice] = twoSigners(runDkg(ids)); + const commit = signingRound1Commit(alice.round3.keyPackage); + + const { hiding, binding } = splitCommitments(commit.commitments); + expect(hiding.length).toBeGreaterThan(0); + expect(binding.length).toBeGreaterThan(0); + + const recombined = combineCommitments(hiding, binding); + expect(recombined).toEqual(commit.commitments); + }); + it("aggregation rejects a mismatched/forged share rather than publishing an invalid signature", () => { const ids = [deviceId(1), deviceId(2), deviceId(THIRD_DEVICE_BYTE)]; const [alice, bob] = twoSigners(runDkg(ids)); From 5dde09143c88922b7a44c0eb5a4a4c1220afc46f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:17:28 +0100 Subject: [PATCH 07/15] fix(core): satisfy eslint/tsc across the threshold test fixtures and 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 "=>". --- .../core/src/domain/threshold-network.ts | 12 ++-- .../core/test/memory-nonce-store.unit.test.ts | 42 +++++++----- .../core/test/threshold-network.unit.test.ts | 67 ++++++++++++++----- .../threshold-share-envelope.unit.test.ts | 48 +++++++++---- ts/packages/core/test/tokens-fixtures.ts | 10 +-- 5 files changed, 125 insertions(+), 54 deletions(-) diff --git a/ts/packages/core/src/domain/threshold-network.ts b/ts/packages/core/src/domain/threshold-network.ts index b1c17ee..1de292f 100644 --- a/ts/packages/core/src/domain/threshold-network.ts +++ b/ts/packages/core/src/domain/threshold-network.ts @@ -20,9 +20,11 @@ import type { ThresholdSubject } from "./threshold-subject.js"; /** Gates threshold.commit/.sign/.abort -- the two-round signing protocol and its abort path. */ export const THRESHOLD_SIGN_VERB: CapabilityVerb = "exadev.io/threshold:sign"; /** Gates threshold.keygen-round1/round2/confirm when `existing-group-key` is absent on round1 -- a fresh DKG among devices that already trust each other. */ -export const THRESHOLD_KEYGEN_VERB: CapabilityVerb = "exadev.io/threshold:keygen"; +export const THRESHOLD_KEYGEN_VERB: CapabilityVerb = + "exadev.io/threshold:keygen"; /** Gates the same keygen-round1/round2/confirm triplet when `existing-group-key` is present -- resharing can redefine the participant set entirely and is strictly more dangerous than an initial keygen, so it is a separately grantable and separately revocable capability (spec/threshold.cddl's own resolved design). */ -export const THRESHOLD_RESHARE_VERB: CapabilityVerb = "exadev.io/threshold:reshare"; +export const THRESHOLD_RESHARE_VERB: CapabilityVerb = + "exadev.io/threshold:reshare"; /** No path: `group` names the specific group within the params themselves (threshold-commit/-sign) or is established by the ceremony's own participant set (keygen-round1/round2/confirm) -- there is no filesystem-subtree-style resource to scope by path. */ export const THRESHOLD_GROUP_SCOPE: CapabilityScope = { kind: "group" }; @@ -33,9 +35,7 @@ export function keygenCapabilityVerb(isReshare: boolean): CapabilityVerb { } function hasVerb(params: ManageCommandParams, verb: string): boolean { - return ( - typeof params === "object" && "verb" in params && params.verb === verb - ); + return typeof params === "object" && "verb" in params && params.verb === verb; } export function isThresholdCommit( @@ -126,7 +126,7 @@ export function buildAbortCommand( export interface KeygenRound1Options { /** REQUIRED for a fresh DKG (existingGroupKey absent) -- load-bearing, not ceremonial: without it a participant broadcasting last could adaptively bias the resulting group key (the rogue-key attack). MAY be omitted for a reshare. */ proofOfKnowledge?: Uint8Array; - /** Present => this is a reshare of the named group's existing Ed25519 public key; absent => a fresh DKG. Determines this command's own capability verb via keygenCapabilityVerb. */ + /** Present: this is a reshare of the named group's existing Ed25519 public key. Absent: a fresh DKG. Determines this command's own capability verb via keygenCapabilityVerb. */ existingGroupKey?: Uint8Array; } diff --git a/ts/packages/core/test/memory-nonce-store.unit.test.ts b/ts/packages/core/test/memory-nonce-store.unit.test.ts index 0bf28d5..a9e9437 100644 --- a/ts/packages/core/test/memory-nonce-store.unit.test.ts +++ b/ts/packages/core/test/memory-nonce-store.unit.test.ts @@ -2,39 +2,51 @@ import { describe, expect, it } from "vitest"; import { createMemoryNonceStore } from "../src/adapters/memory-nonce-store.js"; import { NonceStoreError } from "../src/ports/nonce-store.js"; +const SESSION_A = 1n; +const SESSION_B = 2n; +const UNKNOWN_SESSION = 999n; +const THIRD_NONCE_BYTE = 3; +const NONCE_BYTES = [1, 2, THIRD_NONCE_BYTE]; +const SOLE_NONCE_BYTE = 9; +const OTHER_NONCE_BYTES = [SOLE_NONCE_BYTE]; + describe("memory-nonce-store: createMemoryNonceStore", () => { it("take is one-shot -- a second call for the same session rejects", async () => { const store = createMemoryNonceStore(); - await store.persist(1n, new Uint8Array([1, 2, 3])); - await expect(store.take(1n)).resolves.toEqual(new Uint8Array([1, 2, 3])); - await expect(store.take(1n)).rejects.toThrow(NonceStoreError); + await store.persist(SESSION_A, new Uint8Array(NONCE_BYTES)); + await expect(store.take(SESSION_A)).resolves.toEqual( + new Uint8Array(NONCE_BYTES), + ); + await expect(store.take(SESSION_A)).rejects.toThrow(NonceStoreError); }); it("persist refuses to overwrite an existing session", async () => { const store = createMemoryNonceStore(); - await store.persist(1n, new Uint8Array([1])); - await expect(store.persist(1n, new Uint8Array([2]))).rejects.toThrow( - NonceStoreError, - ); + await store.persist(SESSION_A, new Uint8Array(NONCE_BYTES)); + await expect( + store.persist(SESSION_A, new Uint8Array(OTHER_NONCE_BYTES)), + ).rejects.toThrow(NonceStoreError); }); it("discard makes a later take fail closed", async () => { const store = createMemoryNonceStore(); - await store.persist(1n, new Uint8Array([9])); - await store.discard(1n); - await expect(store.take(1n)).rejects.toThrow(NonceStoreError); + await store.persist(SESSION_A, new Uint8Array(OTHER_NONCE_BYTES)); + await store.discard(SESSION_A); + await expect(store.take(SESSION_A)).rejects.toThrow(NonceStoreError); }); it("discard of an unknown session is a no-op, never rejects", async () => { const store = createMemoryNonceStore(); - await expect(store.discard(999n)).resolves.toBeUndefined(); + await expect(store.discard(UNKNOWN_SESSION)).resolves.toBeUndefined(); }); it("keeps sessions independent by session-id", async () => { const store = createMemoryNonceStore(); - await store.persist(1n, new Uint8Array([1])); - await store.persist(2n, new Uint8Array([2])); - await expect(store.take(2n)).resolves.toEqual(new Uint8Array([2])); - await expect(store.take(1n)).resolves.toEqual(new Uint8Array([1])); + const bytesA = new Uint8Array([1]); + const bytesB = new Uint8Array([2]); + await store.persist(SESSION_A, bytesA); + await store.persist(SESSION_B, bytesB); + await expect(store.take(SESSION_B)).resolves.toEqual(bytesB); + await expect(store.take(SESSION_A)).resolves.toEqual(bytesA); }); }); diff --git a/ts/packages/core/test/threshold-network.unit.test.ts b/ts/packages/core/test/threshold-network.unit.test.ts index 5bfa8fc..5031aa7 100644 --- a/ts/packages/core/test/threshold-network.unit.test.ts +++ b/ts/packages/core/test/threshold-network.unit.test.ts @@ -22,6 +22,16 @@ import { import type { ThresholdSubject } from "../src/domain/threshold-subject.js"; const DEVICE_ID_LENGTH = 32; +const GROUP_DEVICE_BYTE = 9; +const DEADLINE_MS = 1_000; +const THRESHOLD = 2; +const COMMIT_SESSION_ID = 1n; +const SIGN_SESSION_ID = 2n; +const ABORT_SESSION_ID = 3n; +const FRESH_DKG_SESSION_ID = 4n; +const RESHARE_SESSION_ID = 5n; +const ROUND2_CONFIRM_SESSION_ID = 6n; +const GROUP_KEY_FILL_BYTE = 7; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); @@ -37,22 +47,31 @@ const subject: ThresholdSubject = { describe("threshold-network: command builders and type guards", () => { it("buildCommitCommand carries the group-scoped exadev.io/threshold:sign verb", () => { - const command = buildCommitCommand(1n, deviceId(9), subject, 1_000); + const command = buildCommitCommand( + COMMIT_SESSION_ID, + deviceId(GROUP_DEVICE_BYTE), + subject, + DEADLINE_MS, + ); expect(command.verb).toBe(THRESHOLD_SIGN_VERB); expect(isThresholdCommit(command.params)).toBe(true); if (!isThresholdCommit(command.params)) { throw new Error("expected threshold.commit params"); } - expect(command.params["session-id"]).toBe(1); - expect(command.params.deadline).toBe(1_000); + expect(command.params["session-id"]).toBe(Number(COMMIT_SESSION_ID)); + expect(command.params.deadline).toBe(DEADLINE_MS); expect(command.params.subject).toEqual(subject); }); it("buildSignCommand round-trips its commitments", () => { const commitments = [ - { participant: deviceId(1), hiding: new Uint8Array([1]), binding: new Uint8Array([2]) }, + { + participant: deviceId(1), + hiding: new Uint8Array([1]), + binding: new Uint8Array([2]), + }, ]; - const command = buildSignCommand(2n, commitments); + const command = buildSignCommand(SIGN_SESSION_ID, commitments); expect(isThresholdSign(command.params)).toBe(true); if (!isThresholdSign(command.params)) { throw new Error("expected threshold.sign params"); @@ -61,13 +80,13 @@ describe("threshold-network: command builders and type guards", () => { }); it("buildAbortCommand omits reason when not given, includes it when given", () => { - const withoutReason = buildAbortCommand(3n); + const withoutReason = buildAbortCommand(ABORT_SESSION_ID); if (!isThresholdAbort(withoutReason.params)) { throw new Error("expected threshold.abort params"); } expect(withoutReason.params.reason).toBeUndefined(); - const withReason = buildAbortCommand(3n, "timed out"); + const withReason = buildAbortCommand(ABORT_SESSION_ID, "timed out"); if (!isThresholdAbort(withReason.params)) { throw new Error("expected threshold.abort params"); } @@ -80,22 +99,32 @@ describe("threshold-network: command builders and type guards", () => { }); it("buildKeygenRound1Command carries the fresh-DKG shape with proof-of-knowledge, no existing-group-key", () => { - const command = buildKeygenRound1Command(4n, 2, [deviceId(1), deviceId(2)], [new Uint8Array([1])], { - proofOfKnowledge: new Uint8Array([9]), - }); + const command = buildKeygenRound1Command( + FRESH_DKG_SESSION_ID, + THRESHOLD, + [deviceId(1), deviceId(2)], + [new Uint8Array([1])], + { proofOfKnowledge: new Uint8Array([GROUP_DEVICE_BYTE]) }, + ); expect(command.verb).toBe(THRESHOLD_KEYGEN_VERB); if (!isThresholdKeygenRound1(command.params)) { throw new Error("expected threshold.keygen-round1 params"); } - expect(command.params["proof-of-knowledge"]).toEqual(new Uint8Array([9])); + expect(command.params["proof-of-knowledge"]).toEqual( + new Uint8Array([GROUP_DEVICE_BYTE]), + ); expect(command.params["existing-group-key"]).toBeUndefined(); }); it("buildKeygenRound1Command carries the reshare shape with existing-group-key, no proof-of-knowledge required", () => { - const groupKey = new Uint8Array(32).fill(7); - const command = buildKeygenRound1Command(5n, 2, [deviceId(1)], [new Uint8Array([1])], { - existingGroupKey: groupKey, - }); + const groupKey = new Uint8Array(DEVICE_ID_LENGTH).fill(GROUP_KEY_FILL_BYTE); + const command = buildKeygenRound1Command( + RESHARE_SESSION_ID, + THRESHOLD, + [deviceId(1)], + [new Uint8Array([1])], + { existingGroupKey: groupKey }, + ); expect(command.verb).toBe(THRESHOLD_RESHARE_VERB); if (!isThresholdKeygenRound1(command.params)) { throw new Error("expected threshold.keygen-round1 params"); @@ -105,12 +134,16 @@ describe("threshold-network: command builders and type guards", () => { }); it("buildKeygenRound2Command and buildKeygenConfirmCommand use the given ceremony's own capability verb", () => { - const round2 = buildKeygenRound2Command(6n, new Uint8Array([1]), true); + const round2 = buildKeygenRound2Command( + ROUND2_CONFIRM_SESSION_ID, + new Uint8Array([1]), + true, + ); expect(round2.verb).toBe(THRESHOLD_RESHARE_VERB); expect(isThresholdKeygenRound2(round2.params)).toBe(true); const confirm = buildKeygenConfirmCommand( - 6n, + ROUND2_CONFIRM_SESSION_ID, new Uint8Array([1]), new Uint8Array([2]), false, diff --git a/ts/packages/core/test/threshold-share-envelope.unit.test.ts b/ts/packages/core/test/threshold-share-envelope.unit.test.ts index 7434aa6..8055c65 100644 --- a/ts/packages/core/test/threshold-share-envelope.unit.test.ts +++ b/ts/packages/core/test/threshold-share-envelope.unit.test.ts @@ -6,8 +6,22 @@ import { } from "../src/domain/threshold-share-envelope.js"; const DEVICE_ID_LENGTH = 32; +const GROUP_DEVICE_BYTE = 9; +const OTHER_GROUP_DEVICE_BYTE = 1; +const FIRST_SESSION_ID = 42n; +const SECOND_SESSION_ID = 7n; +const THIRD_SESSION_ID = 1n; +const THIRD_BYTE = 3; +const FOURTH_BYTE = 4; +const FIFTH_BYTE = 5; +const SIXTH_BYTE = 6; +const SEVENTH_BYTE = 7; +const SHARE_BYTES = [1, 2, THIRD_BYTE, FOURTH_BYTE]; +const OTHER_SHARE_BYTES = [FIFTH_BYTE, SIXTH_BYTE, SEVENTH_BYTE]; +const TAMPER_SHARE_BYTES = [1, 2, THIRD_BYTE]; +const XOR_MASK = 0xff; -function deviceId(byte: number): Uint8Array { +function deviceId(byte: number): Uint8Array { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; return bytes; @@ -16,14 +30,19 @@ function deviceId(byte: number): Uint8Array { describe("threshold-share-envelope: mint/verify", () => { it("mint then verify round-trips and the claims match", async () => { const personal = await generateEd25519Identity(); - const group = deviceId(9); - const shareBytes = new Uint8Array([1, 2, 3, 4]); + const group = deviceId(GROUP_DEVICE_BYTE); + const shareBytes = new Uint8Array(SHARE_BYTES); - const envelope = await mintShareEnvelope(personal, 42n, group, shareBytes); + const envelope = await mintShareEnvelope( + personal, + FIRST_SESSION_ID, + group, + shareBytes, + ); const claims = await verifyShareEnvelope(personal, envelope); expect(claims).toBeDefined(); - expect(claims?.sessionId).toBe(42n); + expect(claims?.sessionId).toBe(FIRST_SESSION_ID); expect(claims?.group).toEqual(group); expect(claims?.share).toEqual(shareBytes); expect(claims?.issuer).toEqual(personal.deviceId); @@ -32,13 +51,13 @@ describe("threshold-share-envelope: mint/verify", () => { it("verification only needs the embedded issuer-key, not the local verifier's own identity", async () => { const signer = await generateEd25519Identity(); const verifier = await generateEd25519Identity(); - const group = deviceId(1); + const group = deviceId(OTHER_GROUP_DEVICE_BYTE); const envelope = await mintShareEnvelope( signer, - 7n, + SECOND_SESSION_ID, group, - new Uint8Array([5, 6, 7]), + new Uint8Array(OTHER_SHARE_BYTES), ); const claims = await verifyShareEnvelope(verifier, envelope); @@ -47,17 +66,22 @@ describe("threshold-share-envelope: mint/verify", () => { it("a tampered payload fails verification", async () => { const personal = await generateEd25519Identity(); - const group = deviceId(9); + const group = deviceId(GROUP_DEVICE_BYTE); const envelope = await mintShareEnvelope( personal, - 1n, + THIRD_SESSION_ID, group, - new Uint8Array([1, 2, 3]), + new Uint8Array(TAMPER_SHARE_BYTES), ); const tamperedPayload = envelope[2] === null ? null : Uint8Array.from(envelope[2]); if (tamperedPayload !== null) { - tamperedPayload[tamperedPayload.length - 1] ^= 0xff; + const lastIndex = tamperedPayload.length - 1; + const lastByte = tamperedPayload[lastIndex]; + if (lastByte === undefined) { + throw new Error("test fixture: expected a non-empty payload"); + } + tamperedPayload[lastIndex] = lastByte ^ XOR_MASK; } const tampered: typeof envelope = [ envelope[0], diff --git a/ts/packages/core/test/tokens-fixtures.ts b/ts/packages/core/test/tokens-fixtures.ts index 7685134..b5bb8d0 100644 --- a/ts/packages/core/test/tokens-fixtures.ts +++ b/ts/packages/core/test/tokens-fixtures.ts @@ -56,10 +56,12 @@ export async function generateEs256Identity(): Promise { /** A fresh Ed25519 IdentityPort -- the personal-device signing key a threshold-share-envelope is minted under (never the group's own key), distinct from generateEs256Identity's P-256 identity. */ export async function generateEd25519Identity(): Promise { - const keyPair = await webcrypto.subtle.generateKey({ name: "Ed25519" }, true, [ - "sign", - "verify", - ]); + // @types/node's own generateKey overloads mis-resolve a bare { name: "Ed25519" }: it structurally matches KmacKeyGenParams (Algorithm's own `name: string` plus an OPTIONAL `length`), so TypeScript picks the CryptoKey-only overload instead of the CryptoKeyPair-returning EcKeyGenParams one. Supplying `namedCurve` (typed as a bare `string` alias, not a literal union -- Node's own crypto.d.ts declares `type NamedCurve = string`) steers overload resolution to EcKeyGenParams instead, with zero effect on the actual runtime call: Web Crypto dispatches Ed25519 key generation purely on `name` and never reads `namedCurve` for it, so this satisfies the type checker honestly rather than casting past it. + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519", namedCurve: "Ed25519" }, + true, + ["sign", "verify"], + ); const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); From d72d232874ccf63e9cb992424bded4a528775c8f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:17:35 +0100 Subject: [PATCH 08/15] feat(core): wire threshold signing to real manage-request/manage-response 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. --- .../adapters/threshold-network-coordinator.ts | 176 +++++++++++ .../src/adapters/threshold-participant.ts | 255 ++++++++++++++++ .../core/src/domain/threshold-share-wire.ts | 29 ++ ...ld-network-coordinator.integration.test.ts | 279 ++++++++++++++++++ .../test/threshold-participant.unit.test.ts | 206 +++++++++++++ 5 files changed, 945 insertions(+) create mode 100644 ts/packages/core/src/adapters/threshold-network-coordinator.ts create mode 100644 ts/packages/core/src/adapters/threshold-participant.ts create mode 100644 ts/packages/core/src/domain/threshold-share-wire.ts create mode 100644 ts/packages/core/test/threshold-network-coordinator.integration.test.ts create mode 100644 ts/packages/core/test/threshold-participant.unit.test.ts diff --git a/ts/packages/core/src/adapters/threshold-network-coordinator.ts b/ts/packages/core/src/adapters/threshold-network-coordinator.ts new file mode 100644 index 0000000..2fcfb28 --- /dev/null +++ b/ts/packages/core/src/adapters/threshold-network-coordinator.ts @@ -0,0 +1,176 @@ +/** + * A real, wire-driven `ThresholdCoordinator` (`adapters/threshold-identity.ts`): drives the two-round FROST signing protocol over actual `manage-request`/`manage-response` traffic through a `MeshSession`, exactly as `core/webrtc`'s own negotiation does (`webrtc-negotiation.ts`'s own `session.sendManageRequest` usage is the precedent this module mirrors). The prior in-process-only wiring (every participant simulated locally, no network) was `threshold-identity.unit.test.ts`'s own test double; this is the real implementation the group's coordinator role actually runs. + */ +import type { + CapabilityScope, + CapabilityToken, + DeviceId, +} from "../generated/protocol.js"; +import type { IdentityPort } from "../ports/identity.js"; +import type { ManageOutcome, MeshSession } from "../domain/mesh-session.js"; +import { bytesEqual } from "../domain/token-scope.js"; +import { + THRESHOLD_GROUP_SCOPE, + buildCommitCommand, + buildSignCommand, +} from "../domain/threshold-network.js"; +import { decodeShareEnvelopeBytes } from "../domain/threshold-share-wire.js"; +import { verifyShareEnvelope } from "../domain/threshold-share-envelope.js"; +import { + combineCommitments, + splitCommitments, + type DeviceKeyed, +} from "./threshold-wasm.js"; +import type { + SignatureShareEntry, + SigningCommitmentEntry, + ThresholdCoordinator, +} from "./threshold-identity.js"; + +/** The subset of MeshSession this module actually needs -- never the full session surface, matching this project's own minimal-contract convention. Both createThresholdNetworkCoordinator and createThresholdParticipant depend on exactly this same narrow type, never the full MeshSession, so either can be satisfied by a session, a relay-routed session, or a lighter test double with no other MeshSession machinery. */ +export type ThresholdSessionTransport = Pick; + +export interface ThresholdNetworkCoordinatorOptions { + session: Readonly; + /** The group's own device-id -- carried on every threshold.commit request. */ + group: DeviceId; + /** Verifies each collected round-2 share-envelope's self-certification/signature before extracting its raw share. Verification needs no local private key, only the envelope's own embedded issuer-key (see verifyShareEnvelope's own doc comment) -- this is typically the coordinator's own IdentityPort, reused for its ordinary verify capability, not a separate credential. */ + verifierIdentity: Readonly; + scope?: Readonly; + token?: CapabilityToken; + timeoutMs?: number; +} + +function isUint8Array(value: unknown): value is Uint8Array { + return value instanceof Uint8Array; +} + +function isDeviceIdShaped(value: unknown): value is DeviceId { + const DEVICE_ID_BYTE_LENGTH = 32; + return isUint8Array(value) && value.length === DEVICE_ID_BYTE_LENGTH; +} + +interface CommitOkFields { + participant: DeviceId; + hiding: Uint8Array; + binding: Uint8Array; +} + +/** Parses threshold-commit's own manage-ok extension: `participant: device-id, hiding: bstr, binding: bstr`. Returns undefined for anything malformed -- an unauthorised, malformed, or non-responding participant is simply excluded from this round's result, matching commitRound's own documented "either returns enough commitments to proceed or rejects" contract (the caller, ThresholdIdentity.signSubject, is what enforces the threshold count). */ +function parseCommitOk( + outcome: Readonly, +): CommitOkFields | undefined { + if (outcome.result !== "ok") { + return undefined; + } + const { participant, hiding, binding } = outcome; + if ( + !isDeviceIdShaped(participant) || + !isUint8Array(hiding) || + !isUint8Array(binding) + ) { + return undefined; + } + return { participant, hiding, binding }; +} + +/** + * Builds a ThresholdCoordinator that drives commitRound/signRound over a real MeshSession. `group` and `verifierIdentity` are fixed for this coordinator's whole lifetime (a coordinator instance is scoped to one group), matching ThresholdCoordinator's own contract, which deliberately carries no group/verifier parameters of its own -- those are this adapter's own construction-time configuration, not part of the transport-agnostic interface every implementation shares. + */ +export function createThresholdNetworkCoordinator( + options: Readonly, +): ThresholdCoordinator { + const scope = options.scope ?? THRESHOLD_GROUP_SCOPE; + + return { + async commitRound(sessionId, participants, subject, deadlineUnixMs) { + const command = buildCommitCommand( + sessionId, + options.group, + subject, + deadlineUnixMs, + ); + const responses = await Promise.all( + participants.map( + async (participant): Promise => { + const outcome = await options.session.sendManageRequest( + command, + scope, + participant, + options.token, + options.timeoutMs, + ); + const parsed = parseCommitOk(outcome); + if (parsed === undefined) { + return undefined; + } + if (!bytesEqual(parsed.participant, participant)) { + return undefined; + } + return { + deviceId: participant, + value: combineCommitments(parsed.hiding, parsed.binding), + }; + }, + ), + ); + return responses.filter( + (entry): entry is SigningCommitmentEntry => entry !== undefined, + ); + }, + + async signRound(sessionId, commitments) { + const wireCommitments = commitments.map((entry: DeviceKeyed) => { + const { hiding, binding } = splitCommitments(entry.value); + return { participant: entry.deviceId, hiding, binding }; + }); + const command = buildSignCommand(sessionId, wireCommitments); + + const responses = await Promise.all( + commitments.map( + async ({ + deviceId: participant, + }): Promise => { + const outcome = await options.session.sendManageRequest( + command, + scope, + participant, + options.token, + options.timeoutMs, + ); + if (outcome.result !== "ok") { + return undefined; + } + const shareBytes = outcome.share; + if (!isUint8Array(shareBytes)) { + return undefined; + } + const envelope = decodeShareEnvelopeBytes(shareBytes); + if (envelope === undefined) { + return undefined; + } + const claims = await verifyShareEnvelope( + options.verifierIdentity, + envelope, + ); + if (claims === undefined) { + return undefined; + } + // The envelope must be for THIS session and group, and issued by the exact participant this request was addressed to -- an envelope can be validly signed by its own issuer and still be the wrong envelope for this round (replayed from a different session, or from a different participant entirely), matching unwrap_and_aggregate's own checks on the Rust side. + if ( + claims.sessionId !== sessionId || + !bytesEqual(claims.group, options.group) || + !bytesEqual(claims.issuer, participant) + ) { + return undefined; + } + return { deviceId: participant, value: claims.share }; + }, + ), + ); + return responses.filter( + (entry): entry is SignatureShareEntry => entry !== undefined, + ); + }, + }; +} diff --git a/ts/packages/core/src/adapters/threshold-participant.ts b/ts/packages/core/src/adapters/threshold-participant.ts new file mode 100644 index 0000000..c072ddc --- /dev/null +++ b/ts/packages/core/src/adapters/threshold-participant.ts @@ -0,0 +1,255 @@ +/** + * The participant side of the real, wire-driven `exadev.io/threshold:sign` protocol: consumes a `MeshSession`'s `incomingManageRequests`, answers `threshold.commit`/`threshold.sign`/`threshold.abort` using the real wasm-backed FROST crypto (`adapters/threshold-wasm.ts`), and mints each round-2 share as a real `threshold-share-envelope` (`domain/threshold-share-envelope.ts`) signed under this device's own PERSONAL identity, never the group's. The coordinator-facing counterpart is `adapters/threshold-network-coordinator.ts`'s `createThresholdNetworkCoordinator`. + * + * Returning a round-1 commitment IS this participant's act of authorisation (`domain/threshold-subject.ts`'s own comment) -- `authorise` runs BEFORE any commitment is produced, and `refuseUnrecognisedKind` is always checked first regardless of what `authorise` itself returns, since an unrecognised `kind` is this spec's own non-negotiable fail-closed default. + */ +import type { DeviceId } from "../generated/protocol.js"; +import type { Clock } from "../ports/clock.js"; +import type { IdentityPort } from "../ports/identity.js"; +import type { NonceStore } from "../ports/nonce-store.js"; +import { deviceIdToHex } from "../domain/device-id.js"; +import type { + IncomingManageRequest, + MeshSession, +} from "../domain/mesh-session.js"; +import { + THRESHOLD_GROUP_SCOPE, + THRESHOLD_SIGN_VERB, + isThresholdAbort, + isThresholdCommit, + isThresholdSign, +} from "../domain/threshold-network.js"; +import { mintShareEnvelope } from "../domain/threshold-share-envelope.js"; +import { encodeShareEnvelope } from "../domain/threshold-share-wire.js"; +import { + refuseUnrecognisedKind, + toBeSigned, + type SubjectDecision, + type ThresholdSubject, +} from "../domain/threshold-subject.js"; +import { + type RevocationCheck, + verifyCapabilityToken, +} from "../domain/tokens.js"; +import { + combineCommitments, + signingBuildPackage, + signingRound1Commit, + signingRound2Sign, + splitCommitments, + type DeviceKeyed, +} from "./threshold-wasm.js"; + +/** The subset of MeshSession this responder actually consumes. */ +export type ThresholdParticipantSession = Pick< + MeshSession, + "incomingManageRequests" +>; + +export interface ThresholdParticipantOptions { + session: Readonly; + /** This participant's own personal signing identity -- what every round-2 share-envelope is minted under, never the group's own key. Also doubles as the `identity` a request's own capability token is checked against: token verification is a property of the given key, not the local identity, so this participant's own identity works for that role too (see verifyShareEnvelope's own doc comment for the identical reasoning applied to an envelope's issuer-key). */ + personalIdentity: Readonly; + /** This participant's own FROST key package bytes, keyed by the owning group's device-id (lowercase hex, via deviceIdToHex) -- a participant may hold a share in more than one group. */ + keyPackagesByGroup: ReadonlyMap; + nonceStore: Readonly; + clock: Readonly; + /** No silent default: a caller with no revocation-gossip ingestion wired yet must opt in explicitly (e.g. web-console's own exported `noRevocationCheck`), matching webrtc-signaling.ts's own convention for exactly this situation. */ + revocation: Readonly; + /** Runs after refuseUnrecognisedKind already passed -- an additional, caller-supplied content policy over a subject this participant is being asked to co-sign. Returning undefined defers to "authorised" (refuseUnrecognisedKind's own pass is enough on its own); returning a decision short-circuits either way. Absent, every recognised kind is authorised with no further scrutiny. */ + authorise?: ( + subject: Readonly, + ) => SubjectDecision | undefined; +} + +interface SigningSessionState { + message: Uint8Array; + keyPackage: Uint8Array; + group: DeviceId; +} + +/** True when an incoming threshold.commit/.sign/.abort carries a currently-valid exadev.io/threshold:sign token scoped to "group" -- mirrors authorizeIncomingOffer's own exact structure (webrtc-signaling.ts) for the sibling domain. expectedBearer is deliberately not checked, for the identical reason that function documents: neither a browser client's MeshSession nor a relay-routed one exposes a way to learn a peer's device-id independently of the token itself. */ +async function authorizeIncomingRequest( + incoming: Readonly, + options: Readonly, +): Promise { + if (incoming.token === undefined) { + return false; + } + const verdict = await verifyCapabilityToken(incoming.token, { + identity: options.personalIdentity, + clock: options.clock, + revocation: options.revocation, + }); + if (!verdict.ok) { + return false; + } + return ( + verdict.claims.capability === THRESHOLD_SIGN_VERB && + verdict.claims.scope.kind === THRESHOLD_GROUP_SCOPE.kind + ); +} + +/** + * Starts consuming `session.incomingManageRequests`, answering threshold.commit/.sign/.abort for as long as the underlying stream produces requests. Returns nothing to await -- like `webrtc-negotiation.ts`'s own `consumeIncoming`, this is a fire-and-forget background loop for the lifetime of the session; a caller that needs to stop it closes the session itself (there is no separate cancellation handle, matching this package's own MeshSession lifecycle). + */ +export function startThresholdParticipant( + options: Readonly, +): void { + const sessions = new Map(); + + function decide(subject: Readonly): SubjectDecision { + const refusal = refuseUnrecognisedKind(subject); + if (refusal !== undefined) { + return refusal; + } + return options.authorise?.(subject) ?? { authorise: true }; + } + + async function handleCommit( + incoming: Readonly, + params: { + "session-id": number; + group: DeviceId; + subject: ThresholdSubject; + deadline: number; + }, + ): Promise { + if (params.deadline <= options.clock.now()) { + await incoming.respond({ result: "error", code: "deadline-passed" }); + return; + } + const keyPackage = options.keyPackagesByGroup.get( + deviceIdToHex(params.group), + ); + if (keyPackage === undefined) { + await incoming.respond({ result: "error", code: "unknown-group" }); + return; + } + const decision = decide(params.subject); + if (!decision.authorise) { + await incoming.respond({ + result: "error", + code: "refused", + message: decision.reason, + }); + return; + } + + const sessionKey = String(params["session-id"]); + const message = toBeSigned(params.subject); + const { nonces, commitments } = signingRound1Commit(keyPackage); + await options.nonceStore.persist(BigInt(params["session-id"]), nonces); + sessions.set(sessionKey, { message, keyPackage, group: params.group }); + + const { hiding, binding } = splitCommitments(commitments); + await incoming.respond({ + result: "ok", + participant: options.personalIdentity.deviceId, + hiding, + binding, + }); + } + + async function handleSign( + incoming: Readonly, + params: { + "session-id": number; + commitments: readonly { + participant: Uint8Array; + hiding: Uint8Array; + binding: Uint8Array; + }[]; + }, + ): Promise { + const sessionKey = String(params["session-id"]); + const sessionId = BigInt(params["session-id"]); + const state = sessions.get(sessionKey); + if (state === undefined) { + await options.nonceStore.discard(sessionId); + await incoming.respond({ + result: "error", + code: "no-such-session", + message: + "threshold.sign for a session-id with no prior threshold.commit", + }); + return; + } + + let nonces: Uint8Array; + try { + nonces = await options.nonceStore.take(sessionId); + } catch { + sessions.delete(sessionKey); + await incoming.respond({ + result: "error", + code: "nonce-unavailable", + message: + "no persisted, unused nonce pair for this session -- already released, expired, or never persisted", + }); + return; + } + + const commitmentEntries: DeviceKeyed[] = params.commitments.map((c) => ({ + deviceId: Uint8Array.from(c.participant), + value: combineCommitments(c.hiding, c.binding), + })); + const signingPackage = signingBuildPackage( + commitmentEntries, + state.message, + ); + const share = signingRound2Sign(nonces, signingPackage, state.keyPackage); + + const envelope = await mintShareEnvelope( + options.personalIdentity, + sessionId, + state.group, + share, + ); + sessions.delete(sessionKey); + await incoming.respond({ + result: "ok", + share: encodeShareEnvelope(envelope), + }); + } + + async function handleAbort( + incoming: Readonly, + params: Readonly<{ "session-id": number }>, + ): Promise { + const sessionId = BigInt(params["session-id"]); + sessions.delete(String(params["session-id"])); + await options.nonceStore.discard(sessionId); + await incoming.respond({ result: "ok" }); + } + + async function consume(): Promise { + for await (const incoming of options.session.incomingManageRequests) { + if (incoming.command.verb !== THRESHOLD_SIGN_VERB) { + continue; + } + const authorized = await authorizeIncomingRequest(incoming, options); + if (!authorized) { + await incoming.respond({ result: "error", code: "unauthorized" }); + continue; + } + const params = incoming.command.params; + if (isThresholdCommit(params)) { + await handleCommit(incoming, { + "session-id": params["session-id"], + group: params.group, + subject: params.subject, + deadline: params.deadline, + }); + } else if (isThresholdSign(params)) { + await handleSign(incoming, { + "session-id": params["session-id"], + commitments: params.commitments, + }); + } else if (isThresholdAbort(params)) { + await handleAbort(incoming, { "session-id": params["session-id"] }); + } + } + } + void consume(); +} diff --git a/ts/packages/core/src/domain/threshold-share-wire.ts b/ts/packages/core/src/domain/threshold-share-wire.ts new file mode 100644 index 0000000..32c1737 --- /dev/null +++ b/ts/packages/core/src/domain/threshold-share-wire.ts @@ -0,0 +1,29 @@ +/** + * `share: bstr .cbor threshold-share-envelope` -- threshold-sign's own manage-ok extension carries the envelope nested one level deeper than the generic `ManageOk`/`ManageError` codec decodes: the outer bstr comes back as a plain `Uint8Array` (ManageOk's own extension tail has no per-field schema, see manage-ok's `catchall(z.unknown())`), and the CBOR-encoded `threshold-share-envelope` inside it needs its own decode step -- the same "outer bstr, nested CBOR" pattern `token-claims.parent`/`revocation-announce-frame`'s own entries already use for a self-certifying structure embedded inside another. + */ +import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; +import { + coseSign1Schema, + type ThresholdShareEnvelope, +} from "../generated/protocol.js"; + +/** Decodes threshold-sign's own `share` field (the outer bstr a generic manage-ok decode already produced) into its nested threshold-share-envelope. Returns undefined for anything malformed, never throws -- hostile/corrupted input produces a verdict at the call site, matching this codebase's own fail-closed convention for self-certifying structures. */ +export function decodeShareEnvelopeBytes( + bytes: Uint8Array, +): ThresholdShareEnvelope | undefined { + let decoded: unknown; + try { + decoded = decode(bytes, cdeDecodeOptions); + } catch { + return undefined; + } + const result = coseSign1Schema.safeParse(decoded); + return result.success ? result.data : undefined; +} + +/** Encodes a threshold-share-envelope as the bytes threshold-sign's own manage-ok `share` field carries -- the inverse of decodeShareEnvelopeBytes. */ +export function encodeShareEnvelope( + envelope: Readonly, +): Uint8Array { + return Uint8Array.from(encode(envelope, cdeEncodeOptions)); +} diff --git a/ts/packages/core/test/threshold-network-coordinator.integration.test.ts b/ts/packages/core/test/threshold-network-coordinator.integration.test.ts new file mode 100644 index 0000000..fde5537 --- /dev/null +++ b/ts/packages/core/test/threshold-network-coordinator.integration.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import { createThresholdIdentity } from "../src/adapters/threshold-identity.js"; +import { createThresholdNetworkCoordinator } from "../src/adapters/threshold-network-coordinator.js"; +import { startThresholdParticipant } from "../src/adapters/threshold-participant.js"; +import { createMemoryNonceStore } from "../src/adapters/memory-nonce-store.js"; +import { deviceIdToHex } from "../src/domain/device-id.js"; +import type { + ManageCommand, + CapabilityScope, + DeviceId, +} from "../src/generated/protocol.js"; +import type { + IncomingManageRequest, + ManageOutcome, + MeshSession, +} from "../src/domain/mesh-session.js"; +import { mintCapabilityToken } from "../src/domain/tokens.js"; +import { deriveDeviceId } from "../src/adapters/node-identity.js"; +import { + toBeSigned, + type ThresholdSubject, +} from "../src/domain/threshold-subject.js"; +import { + dkgRound1, + dkgRound2, + dkgRound3, + type DeviceKeyed, +} from "../src/adapters/threshold-wasm.js"; +import { + fixedClock, + generateEd25519Identity, + neverRevoked, + nextTokenId, +} from "./tokens-fixtures.js"; + +const DEVICE_ID_LENGTH = 32; +const THRESHOLD = 2; +const PARTICIPANTS = 3; +const SIGN_DEADLINE_MS = 60_000; +const NOW_MS = 1_000_000; +const HOUR_MS = 3_600_000; +const EXPIRES_MS = NOW_MS + HOUR_MS; +const THIRD_DEVICE_BYTE = 3; + +function deviceId(byte: number): DeviceId { + const bytes = new Uint8Array(DEVICE_ID_LENGTH); + bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; +} + +function sameDeviceId(a: DeviceId, b: DeviceId): boolean { + return Buffer.from(a).equals(Buffer.from(b)); +} + +function hexOf(id: DeviceId): string { + return Buffer.from(id).toString("hex"); +} + +interface DkgParticipant { + deviceId: DeviceId; + round3: ReturnType; +} + +/** Runs a fresh T=2-of-3 DKG entirely through the wasm bindings -- the same fixture threshold-identity.unit.test.ts's own inProcessCoordinator test uses, kept local for the identical reason that test states: these tests build a real coordinator/participant pair directly on top of it. */ +function runDkg(deviceIds: readonly DeviceId[]): DkgParticipant[] { + const round1ByDevice = deviceIds.map((id) => ({ + deviceId: id, + ...dkgRound1(id, PARTICIPANTS, THRESHOLD), + })); + const round1Entries: DeviceKeyed[] = round1ByDevice.map((p) => ({ + deviceId: p.deviceId, + value: p.package, + })); + + const round2ByDevice = round1ByDevice.map((p) => { + const othersRound1 = round1Entries.filter( + (e) => !sameDeviceId(e.deviceId, p.deviceId), + ); + return { + deviceId: p.deviceId, + ...dkgRound2(p.secretPackage, othersRound1), + }; + }); + + const inboxes = new Map( + deviceIds.map((id) => [hexOf(id), []]), + ); + for (const sender of round2ByDevice) { + for (const { deviceId: recipient, value } of sender.outgoing) { + const inbox = inboxes.get(hexOf(recipient)); + if (!inbox) { + throw new Error("test fixture: unknown DKG recipient"); + } + inbox.push({ deviceId: sender.deviceId, value }); + } + } + + return round2ByDevice.map((p) => { + const othersRound1 = round1Entries.filter( + (e) => !sameDeviceId(e.deviceId, p.deviceId), + ); + const inbox = inboxes.get(hexOf(p.deviceId)); + if (!inbox) { + throw new Error("test fixture: missing DKG inbox"); + } + return { + deviceId: p.deviceId, + round3: dkgRound3(p.secretPackage, othersRound1, inbox), + }; + }); +} + +/** A minimal in-process request/response bus satisfying exactly the two MeshSession slices createThresholdNetworkCoordinator and startThresholdParticipant each depend on -- real manage-request/manage-response round trips, just with no real transport underneath, so the SAME production coordinator/participant code this test exercises is what a real MeshSession would run. */ +function createFakeBus(): { + coordinatorSession: Pick; + participantSessionFor: ( + device: DeviceId, + ) => Pick; +} { + interface Inbox { + waiters: ((request: IncomingManageRequest) => void)[]; + backlog: IncomingManageRequest[]; + } + const inboxes = new Map(); + let nextRequestId = 0; + + function inboxFor(hex: string): Inbox { + let inbox = inboxes.get(hex); + if (inbox === undefined) { + inbox = { waiters: [], backlog: [] }; + inboxes.set(hex, inbox); + } + return inbox; + } + + return { + coordinatorSession: { + sendManageRequest: async ( + command: ManageCommand, + scope: Readonly, + targetDevice?: DeviceId, + token?: IncomingManageRequest["token"], + ): Promise => { + if (targetDevice === undefined) { + throw new Error("test bus requires an explicit targetDevice"); + } + const requestId = nextRequestId; + nextRequestId += 1; + return new Promise((resolve) => { + const incoming: IncomingManageRequest = { + requestId, + command, + scope, + ...(token !== undefined ? { token } : {}), + respond: async (outcome: ManageOutcome): Promise => { + resolve(outcome); + return Promise.resolve(); + }, + }; + const inbox = inboxFor(deviceIdToHex(targetDevice)); + const waiter = inbox.waiters.shift(); + if (waiter) { + waiter(incoming); + } else { + inbox.backlog.push(incoming); + } + }); + }, + }, + participantSessionFor: (device: DeviceId) => ({ + incomingManageRequests: { + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => + new Promise((resolve) => { + const inbox = inboxFor(deviceIdToHex(device)); + const backlogItem = inbox.backlog.shift(); + if (backlogItem) { + resolve({ value: backlogItem, done: false }); + } else { + inbox.waiters.push((request) => { + resolve({ value: request, done: false }); + }); + } + }), + }; + }, + }, + }), + }; +} + +describe("threshold-network-coordinator + threshold-participant: real manage-request round trip", () => { + it("signSubject produces a group signature via real network-shaped commit/sign traffic", async () => { + const root = await generateEd25519Identity(); + // The FROST identifier every DKG round derives is Identifier::derive(device-id) (identifiers.rs) -- this MUST be each signer's own real personal device-id, the same one it later signs threshold-share-envelopes under, not a placeholder. The third participant plays no signing role in this T=2 test, so it stays a synthetic filler device-id. + const personalA = await generateEd25519Identity(); + const personalB = await generateEd25519Identity(); + const ids = [ + personalA.deviceId, + personalB.deviceId, + deviceId(THIRD_DEVICE_BYTE), + ]; + const participants = runDkg(ids); + const [first, second] = participants; + if (!first || !second) { + throw new Error("test fixture: expected at least two DKG participants"); + } + const group = first.round3.groupVerifyingKey; + const groupDeviceId = await deriveDeviceId(group); + + const signers = [ + { dkg: first, personal: personalA }, + { dkg: second, personal: personalB }, + ]; + + const clock = fixedClock(NOW_MS); + const mint = await mintCapabilityToken({ + identity: root, + clock, + tokenId: nextTokenId(), + bearer: personalA.deviceId, + capability: "exadev.io/threshold:sign", + scope: { kind: "group" }, + expires: EXPIRES_MS, + }); + if (!mint.ok) { + throw new Error(`test fixture: token mint failed: ${mint.reason}`); + } + const token = mint.token; + + const bus = createFakeBus(); + for (const { dkg, personal } of signers) { + startThresholdParticipant({ + session: bus.participantSessionFor(personal.deviceId), + personalIdentity: personal, + keyPackagesByGroup: new Map([ + [deviceIdToHex(groupDeviceId), dkg.round3.keyPackage], + ]), + nonceStore: createMemoryNonceStore(), + clock, + revocation: neverRevoked, + }); + } + + const coordinator = createThresholdNetworkCoordinator({ + session: bus.coordinatorSession, + group: groupDeviceId, + verifierIdentity: root, + token, + }); + + const identity = await createThresholdIdentity( + group, + first.round3.publicKeyPackage, + THRESHOLD, + signers.map((s) => s.personal.deviceId), + coordinator, + ); + + const subject: ThresholdSubject = { + kind: "capability-token", + protected: new Uint8Array([1, 2, 1]), + payload: new Uint8Array([1, 2, 2]), + }; + + const signature = await identity.signSubject( + subject, + clock.now() + SIGN_DEADLINE_MS, + ); + + const ok = await identity.verify( + identity.identityKey, + toBeSigned(subject), + signature, + ); + expect(ok).toBe(true); + }); +}); diff --git a/ts/packages/core/test/threshold-participant.unit.test.ts b/ts/packages/core/test/threshold-participant.unit.test.ts new file mode 100644 index 0000000..9570bd4 --- /dev/null +++ b/ts/packages/core/test/threshold-participant.unit.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { startThresholdParticipant } from "../src/adapters/threshold-participant.js"; +import { createMemoryNonceStore } from "../src/adapters/memory-nonce-store.js"; +import { deviceIdToHex } from "../src/domain/device-id.js"; +import { buildCommitCommand } from "../src/domain/threshold-network.js"; +import { THRESHOLD_GROUP_SCOPE } from "../src/domain/threshold-network.js"; +import type { DeviceId } from "../src/generated/protocol.js"; +import type { + IncomingManageRequest, + ManageOutcome, +} from "../src/domain/mesh-session.js"; +import type { ThresholdSubject } from "../src/domain/threshold-subject.js"; +import { mintCapabilityToken } from "../src/domain/tokens.js"; +import { + fixedClock, + generateEd25519Identity, + neverRevoked, +} from "./tokens-fixtures.js"; + +const DEVICE_ID_LENGTH = 32; +const NOW_MS = 1_000_000; +const HOUR_MS = 3_600_000; +const SOON_MS = 1_000; +const GROUP_DEVICE_BYTE = 9; +const FIRST_REQUEST_ID = 0; +const SESSION_ID = 1n; + +function deviceId(byte: number): DeviceId { + const bytes = new Uint8Array(DEVICE_ID_LENGTH); + bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; +} + +/** Sends one bare manage-request directly into a participant's own consume loop (bypassing any coordinator), and resolves with the manage-response it produces -- everything these tests need to exercise a single request/response in isolation. */ +async function sendOnce( + push: (incoming: IncomingManageRequest) => void, + requestId: number, + incoming: Omit, +): Promise { + return new Promise((resolve) => { + push({ + requestId, + ...incoming, + respond: async (outcome) => { + resolve(outcome); + return Promise.resolve(); + }, + }); + }); +} + +function participantHarness(): { + push: (incoming: IncomingManageRequest) => void; + session: { incomingManageRequests: AsyncIterable }; +} { + const waiters: ((request: IncomingManageRequest) => void)[] = []; + const backlog: IncomingManageRequest[] = []; + return { + push: (incoming) => { + const waiter = waiters.shift(); + if (waiter) { + waiter(incoming); + } else { + backlog.push(incoming); + } + }, + session: { + incomingManageRequests: { + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => + new Promise((resolve) => { + const item = backlog.shift(); + if (item) { + resolve({ value: item, done: false }); + } else { + waiters.push((request) => { + resolve({ value: request, done: false }); + }); + } + }), + }; + }, + }, + }, + }; +} + +const subject: ThresholdSubject = { + kind: "capability-token", + protected: new Uint8Array([1]), + payload: new Uint8Array([2]), +}; + +describe("threshold-participant: fail-closed paths", () => { + it("refuses a request with no capability token", async () => { + const harness = participantHarness(); + const personal = await generateEd25519Identity(); + startThresholdParticipant({ + session: harness.session, + personalIdentity: personal, + keyPackagesByGroup: new Map(), + nonceStore: createMemoryNonceStore(), + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + }); + + const command = buildCommitCommand( + SESSION_ID, + deviceId(GROUP_DEVICE_BYTE), + subject, + NOW_MS + SOON_MS, + ); + const outcome = await sendOnce(harness.push, FIRST_REQUEST_ID, { + command, + scope: THRESHOLD_GROUP_SCOPE, + }); + expect(outcome).toEqual({ result: "error", code: "unauthorized" }); + }); + + it("refuses a group this participant holds no key package for", async () => { + const harness = participantHarness(); + const personal = await generateEd25519Identity(); + const root = await generateEd25519Identity(); + const clock = fixedClock(NOW_MS); + const mint = await mintCapabilityToken({ + identity: root, + clock, + tokenId: new Uint8Array([1]), + bearer: personal.deviceId, + capability: "exadev.io/threshold:sign", + scope: { kind: "group" }, + expires: NOW_MS + HOUR_MS, + }); + if (!mint.ok) { + throw new Error(`test fixture: token mint failed: ${mint.reason}`); + } + const { token } = mint; + + startThresholdParticipant({ + session: harness.session, + personalIdentity: personal, + keyPackagesByGroup: new Map(), // no group known + nonceStore: createMemoryNonceStore(), + clock, + revocation: neverRevoked, + }); + + const command = buildCommitCommand( + SESSION_ID, + deviceId(GROUP_DEVICE_BYTE), + subject, + NOW_MS + SOON_MS, + ); + const outcome = await sendOnce(harness.push, FIRST_REQUEST_ID, { + command, + scope: THRESHOLD_GROUP_SCOPE, + token, + }); + expect(outcome).toEqual({ result: "error", code: "unknown-group" }); + }); + + it("refuses a commit whose deadline has already passed", async () => { + const harness = participantHarness(); + const personal = await generateEd25519Identity(); + const root = await generateEd25519Identity(); + const clock = fixedClock(NOW_MS); + const mint = await mintCapabilityToken({ + identity: root, + clock, + tokenId: new Uint8Array([2]), + bearer: personal.deviceId, + capability: "exadev.io/threshold:sign", + scope: { kind: "group" }, + expires: NOW_MS + HOUR_MS, + }); + if (!mint.ok) { + throw new Error(`test fixture: token mint failed: ${mint.reason}`); + } + const { token } = mint; + + startThresholdParticipant({ + session: harness.session, + personalIdentity: personal, + keyPackagesByGroup: new Map([ + [deviceIdToHex(deviceId(GROUP_DEVICE_BYTE)), new Uint8Array()], + ]), + nonceStore: createMemoryNonceStore(), + clock, + revocation: neverRevoked, + }); + + const command = buildCommitCommand( + SESSION_ID, + deviceId(GROUP_DEVICE_BYTE), + subject, + NOW_MS - 1, + ); + const outcome = await sendOnce(harness.push, FIRST_REQUEST_ID, { + command, + scope: THRESHOLD_GROUP_SCOPE, + token, + }); + expect(outcome).toEqual({ result: "error", code: "deadline-passed" }); + }); +}); From 7870f1b0f0eaad21e11277c757a92b555ca2bb92 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:20:38 +0100 Subject: [PATCH 09/15] feat(threshold): split/combine DKG round-1 packages into commitment and 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. --- .../wire-mesh-threshold-wasm/src/lib.rs | 56 +++++++++++++++++++ rust/crates/wire-mesh-threshold/src/dkg.rs | 49 ++++++++++++++++ .../core/src/adapters/threshold-wasm.ts | 35 ++++++++++++ .../core/test/threshold-wasm.unit.test.ts | 19 +++++++ 4 files changed, 159 insertions(+) diff --git a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs index ac2f74e..067247d 100644 --- a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs +++ b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs @@ -265,6 +265,62 @@ pub fn dkg_round3( }) } +#[wasm_bindgen] +pub struct SplitRound1PackageOutput { + commitment: Array, + proof_of_knowledge: Vec, +} + +#[wasm_bindgen] +impl SplitRound1PackageOutput { + #[wasm_bindgen(getter)] + pub fn commitment(&self) -> Array { + self.commitment.clone() + } + + #[wasm_bindgen(getter, js_name = proofOfKnowledge)] + pub fn proof_of_knowledge(&self) -> Vec { + self.proof_of_knowledge.clone() + } +} + +/// Splits a serialized DKG round-1 `Package` (`dkg_round1`'s own `package` +/// output) into `threshold-keygen-round1`'s own wire shape: the Feldman +/// commitment as an array of independently-serialized coefficients +/// (`commitment: [* bstr]`) and the Schnorr proof of knowledge as a +/// separate byte string -- unlike frost-core's own combined-blob +/// serialization. The inverse of [`combine_round1_package`]. +#[wasm_bindgen] +pub fn split_round1_package(package: Vec) -> Result { + let package = dkg_round1_types::Package::deserialize(&package).map_err(js_err)?; + let (commitment, proof_of_knowledge) = + wire_mesh_threshold::dkg::split_round1_package(&package).map_err(js_err)?; + let commitment_array = Array::new(); + for coefficient in &commitment { + commitment_array.push(&Uint8Array::from(coefficient.as_slice())); + } + Ok(SplitRound1PackageOutput { + commitment: commitment_array, + proof_of_knowledge, + }) +} + +/// Reconstructs a serialized DKG round-1 `Package` (the same combined-blob +/// shape `dkg_round2`/`dkg_round3`/`dkg_transcript_digest` expect) from the +/// two independently-serialized wire fields `threshold-keygen-round1` +/// carries. The inverse of [`split_round1_package`]. +#[wasm_bindgen] +pub fn combine_round1_package( + commitment: Array, + proof_of_knowledge: Vec, +) -> Result, JsValue> { + let commitment_bytes = array_to_bytes_vec(&commitment)?; + let package = + wire_mesh_threshold::dkg::combine_round1_package(&commitment_bytes, &proof_of_knowledge) + .map_err(js_err)?; + package.serialize().map_err(js_err) +} + #[wasm_bindgen] pub fn dkg_transcript_digest( all_round1_ids: Array, diff --git a/rust/crates/wire-mesh-threshold/src/dkg.rs b/rust/crates/wire-mesh-threshold/src/dkg.rs index e07e597..cb2e65d 100644 --- a/rust/crates/wire-mesh-threshold/src/dkg.rs +++ b/rust/crates/wire-mesh-threshold/src/dkg.rs @@ -99,6 +99,33 @@ pub fn round3( .map_err(DkgError::from) } +/// Splits a DKG round-1 `Package` into `threshold-keygen-round1`'s own wire +/// shape: the Feldman commitment as independently-serialized coefficients +/// (`commitment: [* bstr]`) and the Schnorr proof of knowledge as a +/// separate `bstr` (`proof-of-knowledge`) -- unlike `Package::serialize()`'s +/// single combined blob, frost-core's own internal format. The inverse of +/// [`combine_round1_package`]. +pub fn split_round1_package( + package: &round1::Package, +) -> Result<(Vec>, Vec), DkgError> { + let commitment = package.commitment().serialize()?; + let proof_of_knowledge = package.proof_of_knowledge().serialize()?; + Ok((commitment, proof_of_knowledge)) +} + +/// Reconstructs a DKG round-1 `Package` from the two independently- +/// serialized wire fields `threshold-keygen-round1` carries. The inverse of +/// [`split_round1_package`]. +pub fn combine_round1_package( + commitment: &[Vec], + proof_of_knowledge: &[u8], +) -> Result { + let commitment = + frost_ed25519::keys::VerifiableSecretSharingCommitment::deserialize(commitment)?; + let proof_of_knowledge = frost_ed25519::Signature::deserialize(proof_of_knowledge)?; + Ok(round1::Package::new(commitment, proof_of_knowledge)) +} + /// The echo-broadcast transcript digest this participant sends on /// `threshold-keygen-confirm`: `SHA-256` over the full ordered (by /// identifier) set of EVERY round-1 package for the ceremony -- including @@ -269,4 +296,26 @@ mod tests { transcript_digest(&view_tampered, group_key.verifying_key()).expect("digest"); assert_ne!(honest_digest, tampered_digest); } + + /// `threshold-keygen-round1`'s own wire shape carries the Feldman + /// commitment as an array of independently-serialized coefficients + /// (`commitment: [* bstr]`) and the Schnorr proof of knowledge as a + /// separate `bstr`, unlike `round1::Package::serialize()`'s single + /// combined blob -- split then combine must round-trip to the identical + /// package a peer's own round2 verifies against. + #[test] + fn split_and_combine_round1_package_round_trips() { + let a = identifier_for_device(&DeviceId::from_bytes([1; 32])).expect("derives"); + let (_secret, package) = round1(a, 2, 2).expect("round1"); + + let (commitment, proof) = split_round1_package(&package).expect("split"); + assert!(!commitment.is_empty()); + let recombined = combine_round1_package(&commitment, &proof).expect("combine"); + assert_eq!(recombined, package); + } + + #[test] + fn combine_round1_package_rejects_malformed_bytes() { + assert!(combine_round1_package(&[vec![0u8; 4]], &[0u8; 4]).is_err()); + } } diff --git a/ts/packages/core/src/adapters/threshold-wasm.ts b/ts/packages/core/src/adapters/threshold-wasm.ts index 9e0107e..52b6315 100644 --- a/ts/packages/core/src/adapters/threshold-wasm.ts +++ b/ts/packages/core/src/adapters/threshold-wasm.ts @@ -73,6 +73,41 @@ export function dkgRound1( }; } +export interface SplitRound1PackageResult { + commitment: Uint8Array[]; + proofOfKnowledge: Uint8Array; +} + +/** Splits a serialized DKG round-1 package (dkgRound1's own `package` output) into `threshold-keygen-round1`'s own wire shape: the Feldman commitment as an array of independently-serialized coefficients (`commitment: [* bstr]`) and the Schnorr proof of knowledge as a separate byte string -- unlike frost-core's own combined-blob serialization. The inverse of combineRound1Package. */ +export function splitRound1Package( + serializedPackage: Uint8Array, +): SplitRound1PackageResult { + const out = wasm.split_round1_package(serializedPackage); + const commitment: Uint8Array[] = []; + for (const coefficient of out.commitment) { + if (!isUint8Array(coefficient)) { + throw new Error( + "wasm returned a non-Uint8Array entry in split_round1_package's own commitment array", + ); + } + commitment.push(toBufferSource(coefficient)); + } + return { + commitment, + proofOfKnowledge: toBufferSource(out.proofOfKnowledge), + }; +} + +/** Reconstructs a serialized DKG round-1 package (the same combined-blob shape dkgRound2/dkgRound3/dkgTranscriptDigest expect) from the two independently-serialized wire fields `threshold-keygen-round1` carries. The inverse of splitRound1Package. */ +export function combineRound1Package( + commitment: readonly Uint8Array[], + proofOfKnowledge: Uint8Array, +): Uint8Array { + return toBufferSource( + wasm.combine_round1_package([...commitment], proofOfKnowledge), + ); +} + export interface DkgRound2Result { secretPackage: Uint8Array; outgoing: DeviceKeyed[]; diff --git a/ts/packages/core/test/threshold-wasm.unit.test.ts b/ts/packages/core/test/threshold-wasm.unit.test.ts index dfdc2fa..d897ad4 100644 --- a/ts/packages/core/test/threshold-wasm.unit.test.ts +++ b/ts/packages/core/test/threshold-wasm.unit.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import type { DeviceId } from "../src/generated/protocol.js"; import { + combineRound1Package, dkgConfirmMatches, dkgRound1, dkgRound2, dkgRound3, dkgTranscriptDigest, keyPackageSigningShare, + splitRound1Package, reshareCombineCommitments, reshareCombineReceivedShares, reshareDerivePublicKeyPackage, @@ -122,6 +124,23 @@ describe("threshold-wasm: DKG", () => { expect(new Set(keys).size).toBe(1); }); + it("splitRound1Package/combineRound1Package round-trips into the identical combined blob dkgRound2 expects", () => { + const ids = [deviceId(1), deviceId(2), deviceId(THIRD_DEVICE_BYTE)]; + const [alice] = runDkg(ids); + if (!alice) { + throw new Error("test fixture: expected at least one DKG participant"); + } + + const { commitment, proofOfKnowledge } = splitRound1Package( + alice.round1.package, + ); + expect(commitment.length).toBeGreaterThan(0); + expect(proofOfKnowledge.length).toBeGreaterThan(0); + + const recombined = combineRound1Package(commitment, proofOfKnowledge); + expect(recombined).toEqual(alice.round1.package); + }); + it("the group device-id is SHA-256 of the group verifying key, the ordinary identity.cddl rule", async () => { const ids = [deviceId(1), deviceId(2), deviceId(THIRD_DEVICE_BYTE)]; const [alice] = runDkg(ids); From 5580cf19642c2ce6a91a70ed8c70f7bafa8d7037 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:23:52 +0100 Subject: [PATCH 10/15] feat(core): orchestrate fresh DKG over real manage-request traffic 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. --- .../core/src/adapters/threshold-dkg.ts | 300 ++++++++++++++++++ .../test/threshold-dkg.integration.test.ts | 191 +++++++++++ 2 files changed, 491 insertions(+) create mode 100644 ts/packages/core/src/adapters/threshold-dkg.ts create mode 100644 ts/packages/core/test/threshold-dkg.integration.test.ts diff --git a/ts/packages/core/src/adapters/threshold-dkg.ts b/ts/packages/core/src/adapters/threshold-dkg.ts new file mode 100644 index 0000000..3414452 --- /dev/null +++ b/ts/packages/core/src/adapters/threshold-dkg.ts @@ -0,0 +1,300 @@ +/** + * Real, wire-driven DKG/reshare session orchestration for `exadev.io/threshold` (wire-mesh#171): the layer `ThresholdIdentity`/`ThresholdCoordinator` already provide for signing, but DKG has no coordinator/participant asymmetry to build on -- every device runs the IDENTICAL choreography (broadcast round 1, exchange round 2 pairwise, confirm), matching Pedersen DKG's own peer-to-peer structure. `runFreshThresholdDkg` is the entry point a caller drives once per device in the ceremony; every device calls it with the SAME `sessionId` and `participants` set. + * + * Distinguishing which peer sent an incoming round1/round2/confirm message requires `IncomingManageRequest.fromDevice`, which `MeshSession` only populates for a relay-routed request (see mesh-session.ts's own doc comment on that field) -- the same reliance `webrtc-negotiation.ts`'s own `handleIncomingOffer` already has on `incoming.fromDevice` for addressing a specific peer. A direct, unrelayed one-to-one MeshSession has no way to disambiguate more than one concurrent peer and is therefore not a fit for this module's own multi-party choreography. + */ +import type { + CapabilityScope, + CapabilityToken, + DeviceId, +} from "../generated/protocol.js"; +import { deviceIdToHex } from "../domain/device-id.js"; +import type { MeshSession } from "../domain/mesh-session.js"; +import { + buildKeygenConfirmCommand, + buildKeygenRound1Command, + buildKeygenRound2Command, + isThresholdKeygenConfirm, + isThresholdKeygenRound1, + isThresholdKeygenRound2, + keygenCapabilityVerb, + THRESHOLD_GROUP_SCOPE, +} from "../domain/threshold-network.js"; +import { + combineRound1Package, + dkgConfirmMatches, + dkgRound1, + dkgRound2, + dkgRound3, + dkgTranscriptDigest, + splitRound1Package, + type DeviceKeyed, +} from "./threshold-wasm.js"; + +/** The subset of MeshSession one participant's DKG/reshare choreography needs -- both directions, since every device is simultaneously a sender (broadcasting its own round1/round2/confirm) and a receiver (of every other participant's). */ +export type ThresholdDkgTransport = Pick< + MeshSession, + "sendManageRequest" | "incomingManageRequests" +>; + +export interface ThresholdDkgResult { + keyPackage: Uint8Array; + publicKeyPackage: Uint8Array; + groupVerifyingKey: Uint8Array; +} + +interface PendingWaiter { + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +/** One (verb-family, session-id)'s worth of per-sender-device delivery: a message that arrives before anyone is waiting for it is buffered; a waiter that arrives before the message does is queued. Mirrors mesh-session.ts's own backlog/waiter pattern for its incomingManageRequests iterator, scoped down to "exactly one value per device-id" rather than an ordered stream. */ +class PerDeviceCollector { + private readonly waiters = new Map>(); + private readonly backlog = new Map(); + private failure: Error | undefined; + + async awaitFrom(deviceHex: string): Promise { + if (this.failure !== undefined) { + throw this.failure; + } + const buffered = this.backlog.get(deviceHex); + if (buffered !== undefined) { + this.backlog.delete(deviceHex); + return buffered; + } + return new Promise((resolve, reject) => { + this.waiters.set(deviceHex, { resolve, reject }); + }); + } + + deliver(deviceHex: string, value: T): void { + const waiter = this.waiters.get(deviceHex); + if (waiter !== undefined) { + this.waiters.delete(deviceHex); + waiter.resolve(value); + } else { + this.backlog.set(deviceHex, value); + } + } + + /** Aborts every pending and future wait -- the ceremony failed for a reason no single awaitFrom call caused (e.g. a peer sent threshold.abort). */ + fail(error: Error): void { + this.failure = error; + for (const waiter of this.waiters.values()) { + waiter.reject(error); + } + this.waiters.clear(); + } +} + +interface Round1Payload { + commitment: Uint8Array[]; + proofOfKnowledge?: Uint8Array; + existingGroupKey?: Uint8Array; +} + +interface ConfirmPayload { + transcriptDigest: Uint8Array; + groupKey: Uint8Array; +} + +interface DkgCollectors { + round1: PerDeviceCollector; + round2: PerDeviceCollector; + confirm: PerDeviceCollector; +} + +/** Starts the single shared consume loop for this device's whole DKG/reshare ceremony: every incoming threshold.keygen-round1/round2/confirm for `sessionId`, from whichever peer sent it (via `incoming.fromDevice` -- see this module's own doc comment), is delivered to the matching collector and acknowledged. Anything else (a different session-id, a message with no fromDevice, an unrecognised verb) is left unanswered, matching webrtc-negotiation.ts's own "not this consumer's business" convention for a shared incoming stream. */ +function startDkgConsumeLoop( + session: Readonly, + expectedVerb: string, + sessionId: bigint, + collectors: Readonly, +): void { + async function consume(): Promise { + for await (const incoming of session.incomingManageRequests) { + if ( + incoming.command.verb !== expectedVerb || + incoming.fromDevice === undefined + ) { + continue; + } + const fromHex = deviceIdToHex(incoming.fromDevice); + const params = incoming.command.params; + if (isThresholdKeygenRound1(params)) { + if (params["session-id"] !== Number(sessionId)) { + continue; + } + collectors.round1.deliver(fromHex, { + commitment: params.commitment, + ...(params["proof-of-knowledge"] !== undefined + ? { proofOfKnowledge: params["proof-of-knowledge"] } + : {}), + ...(params["existing-group-key"] !== undefined + ? { existingGroupKey: params["existing-group-key"] } + : {}), + }); + await incoming.respond({ result: "ok" }); + } else if (isThresholdKeygenRound2(params)) { + if (params["session-id"] !== Number(sessionId)) { + continue; + } + collectors.round2.deliver(fromHex, params.share); + await incoming.respond({ result: "ok" }); + } else if (isThresholdKeygenConfirm(params)) { + if (params["session-id"] !== Number(sessionId)) { + continue; + } + collectors.confirm.deliver(fromHex, { + transcriptDigest: params["transcript-digest"], + groupKey: params["group-key"], + }); + await incoming.respond({ result: "ok" }); + } + } + } + void consume(); +} + +export interface RunFreshThresholdDkgOptions { + session: Readonly; + ownDeviceId: DeviceId; + /** Every OTHER participant in the ceremony -- this device's own id is never included, matching dkgRound2/dkgRound3's own "MUST NOT include this participant's own package" contract. */ + otherParticipants: readonly DeviceId[]; + threshold: number; + sessionId: bigint; + scope?: Readonly; + token?: CapabilityToken; + timeoutMs?: number; +} + +/** Runs a fresh DKG ceremony's full choreography for ONE device: broadcast round 1, exchange round 2 pairwise, compute round 3 locally, then confirm. Every participant calls this with the identical `sessionId` and `otherParticipants`-plus-`ownDeviceId` set. Rejects (without ever returning a result) if any peer's confirm digest or group key mismatches this device's own -- the ceremony MUST abort, never be repaired in place, per spec/threshold.cddl's own confirm-round obligation. */ +export async function runFreshThresholdDkg( + options: Readonly, +): Promise { + const verb = keygenCapabilityVerb(false); + const scope = options.scope ?? THRESHOLD_GROUP_SCOPE; + const maxSigners = options.otherParticipants.length + 1; + const allParticipants = [options.ownDeviceId, ...options.otherParticipants]; + const collectors: DkgCollectors = { + round1: new PerDeviceCollector(), + round2: new PerDeviceCollector(), + confirm: new PerDeviceCollector(), + }; + startDkgConsumeLoop(options.session, verb, options.sessionId, collectors); + + const own = dkgRound1(options.ownDeviceId, maxSigners, options.threshold); + const ownSplit = splitRound1Package(own.package); + + await Promise.all( + options.otherParticipants.map(async (peer) => { + const command = buildKeygenRound1Command( + options.sessionId, + options.threshold, + allParticipants, + ownSplit.commitment, + { proofOfKnowledge: ownSplit.proofOfKnowledge }, + ); + await options.session.sendManageRequest( + command, + scope, + peer, + options.token, + options.timeoutMs, + ); + }), + ); + + const round1Entries: DeviceKeyed[] = await Promise.all( + options.otherParticipants.map(async (peer): Promise => { + const payload = await collectors.round1.awaitFrom(deviceIdToHex(peer)); + if (payload.proofOfKnowledge === undefined) { + throw new Error( + `threshold.keygen-round1 from ${deviceIdToHex(peer)} is missing proof-of-knowledge, REQUIRED for a fresh DKG`, + ); + } + return { + deviceId: peer, + value: combineRound1Package( + payload.commitment, + payload.proofOfKnowledge, + ), + }; + }), + ); + + const round2 = dkgRound2(own.secretPackage, round1Entries); + + await Promise.all( + round2.outgoing.map(async ({ deviceId: peer, value: share }) => { + const command = buildKeygenRound2Command(options.sessionId, share, false); + await options.session.sendManageRequest( + command, + scope, + peer, + options.token, + options.timeoutMs, + ); + }), + ); + + const round2Entries: DeviceKeyed[] = await Promise.all( + options.otherParticipants.map(async (peer): Promise => { + const share = await collectors.round2.awaitFrom(deviceIdToHex(peer)); + return { deviceId: peer, value: Uint8Array.from(share) }; + }), + ); + + const round3 = dkgRound3(round2.secretPackage, round1Entries, round2Entries); + + const allRound1Entries: DeviceKeyed[] = [ + { deviceId: options.ownDeviceId, value: own.package }, + ...round1Entries, + ]; + const ownDigest = dkgTranscriptDigest( + allRound1Entries, + round3.groupVerifyingKey, + ); + + await Promise.all( + options.otherParticipants.map(async (peer) => { + const command = buildKeygenConfirmCommand( + options.sessionId, + ownDigest, + round3.groupVerifyingKey, + false, + ); + await options.session.sendManageRequest( + command, + scope, + peer, + options.token, + options.timeoutMs, + ); + }), + ); + + await Promise.all( + options.otherParticipants.map(async (peer) => { + const confirm = await collectors.confirm.awaitFrom(deviceIdToHex(peer)); + const matches = dkgConfirmMatches( + ownDigest, + round3.groupVerifyingKey, + confirm.transcriptDigest, + confirm.groupKey, + ); + if (!matches) { + const error = new Error( + `DKG echo-broadcast transcript mismatch with ${deviceIdToHex(peer)} -- aborting`, + ); + collectors.round1.fail(error); + collectors.round2.fail(error); + collectors.confirm.fail(error); + throw error; + } + }), + ); + + return round3; +} diff --git a/ts/packages/core/test/threshold-dkg.integration.test.ts b/ts/packages/core/test/threshold-dkg.integration.test.ts new file mode 100644 index 0000000..7b9db99 --- /dev/null +++ b/ts/packages/core/test/threshold-dkg.integration.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { runFreshThresholdDkg } from "../src/adapters/threshold-dkg.js"; +import { deviceIdToHex } from "../src/domain/device-id.js"; +import type { + CapabilityScope, + DeviceId, + ManageCommand, +} from "../src/generated/protocol.js"; +import type { + IncomingManageRequest, + ManageOutcome, + MeshSession, +} from "../src/domain/mesh-session.js"; +import { + signingBuildPackage, + signingRound1Commit, + signingRound2Sign, + signingAggregate, + type DeviceKeyed, +} from "../src/adapters/threshold-wasm.js"; +import { + deriveDeviceId, + verifyWithPublicKey, +} from "../src/adapters/node-identity.js"; + +const DEVICE_ID_LENGTH = 32; +const THRESHOLD = 2; +const SECOND_DEVICE_BYTE = 2; +const THIRD_DEVICE_BYTE = 3; +const SESSION_ID = 1n; +const ALG_ED25519 = -8; + +function deviceId(byte: number): DeviceId { + const bytes = new Uint8Array(DEVICE_ID_LENGTH); + bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; +} + +/** A relay-shaped in-process bus: every device shares ONE addressable inbox, and every delivered manage-request carries the sender's own device-id as `fromDevice` -- the same shape a real relay-routed MeshSession produces (mesh-session.ts's own applyManageRequest(frame, viaRelay=true) path), which runFreshThresholdDkg's own choreography depends on to disambiguate more than one concurrent peer. */ +function createRelayBus(): { + sessionFor: ( + device: DeviceId, + ) => Pick; +} { + interface Inbox { + waiters: ((request: IncomingManageRequest) => void)[]; + backlog: IncomingManageRequest[]; + } + const inboxes = new Map(); + let nextRequestId = 0; + + function inboxFor(hex: string): Inbox { + let inbox = inboxes.get(hex); + if (inbox === undefined) { + inbox = { waiters: [], backlog: [] }; + inboxes.set(hex, inbox); + } + return inbox; + } + + return { + sessionFor: (self: DeviceId) => ({ + sendManageRequest: async ( + command: ManageCommand, + scope: Readonly, + targetDevice?: DeviceId, + ): Promise => { + if (targetDevice === undefined) { + throw new Error("test bus requires an explicit targetDevice"); + } + const requestId = nextRequestId; + nextRequestId += 1; + return new Promise((resolve) => { + const incoming: IncomingManageRequest = { + requestId, + command, + scope, + fromDevice: self, + respond: async (outcome: ManageOutcome): Promise => { + resolve(outcome); + return Promise.resolve(); + }, + }; + const inbox = inboxFor(deviceIdToHex(targetDevice)); + const waiter = inbox.waiters.shift(); + if (waiter) { + waiter(incoming); + } else { + inbox.backlog.push(incoming); + } + }); + }, + incomingManageRequests: { + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => + new Promise((resolve) => { + const inbox = inboxFor(deviceIdToHex(self)); + const backlogItem = inbox.backlog.shift(); + if (backlogItem) { + resolve({ value: backlogItem, done: false }); + } else { + inbox.waiters.push((request) => { + resolve({ value: request, done: false }); + }); + } + }), + }; + }, + }, + }), + }; +} + +describe("threshold-dkg: runFreshThresholdDkg", () => { + it("three devices derive the identical group key via real network-shaped round1/round2/confirm traffic, and the resulting key packages actually sign", async () => { + const alice = deviceId(1); + const bob = deviceId(SECOND_DEVICE_BYTE); + const carol = deviceId(THIRD_DEVICE_BYTE); + const bus = createRelayBus(); + + const [aliceResult, bobResult, carolResult] = await Promise.all([ + runFreshThresholdDkg({ + session: bus.sessionFor(alice), + ownDeviceId: alice, + otherParticipants: [bob, carol], + threshold: THRESHOLD, + sessionId: SESSION_ID, + }), + runFreshThresholdDkg({ + session: bus.sessionFor(bob), + ownDeviceId: bob, + otherParticipants: [alice, carol], + threshold: THRESHOLD, + sessionId: SESSION_ID, + }), + runFreshThresholdDkg({ + session: bus.sessionFor(carol), + ownDeviceId: carol, + otherParticipants: [alice, bob], + threshold: THRESHOLD, + sessionId: SESSION_ID, + }), + ]); + + expect( + deviceIdToHex(await deriveDeviceId(aliceResult.groupVerifyingKey)), + ).toBe(deviceIdToHex(await deriveDeviceId(bobResult.groupVerifyingKey))); + expect(bobResult.groupVerifyingKey).toEqual(carolResult.groupVerifyingKey); + expect(aliceResult.groupVerifyingKey).toEqual(bobResult.groupVerifyingKey); + + // The DKG's own output key packages must be real, usable FROST shares: T=2 of Alice/Bob actually sign, and the aggregate verifies against the group verifying key with ordinary Web Crypto, zero verifier change. + const message = new TextEncoder().encode("dkg produced a real key"); + const aliceCommit = signingRound1Commit(aliceResult.keyPackage); + const bobCommit = signingRound1Commit(bobResult.keyPackage); + const commitEntries: DeviceKeyed[] = [ + { deviceId: alice, value: aliceCommit.commitments }, + { deviceId: bob, value: bobCommit.commitments }, + ]; + const signingPackage = signingBuildPackage(commitEntries, message); + const shares: DeviceKeyed[] = [ + { + deviceId: alice, + value: signingRound2Sign( + aliceCommit.nonces, + signingPackage, + aliceResult.keyPackage, + ), + }, + { + deviceId: bob, + value: signingRound2Sign( + bobCommit.nonces, + signingPackage, + bobResult.keyPackage, + ), + }, + ]; + const signature = signingAggregate( + signingPackage, + shares, + aliceResult.publicKeyPackage, + ); + const ok = await verifyWithPublicKey( + { alg: ALG_ED25519, "public-key": aliceResult.groupVerifyingKey }, + message, + signature, + ); + expect(ok).toBe(true); + }); +}); From 102e3e89cf863a547f1dcacf8a5052294edebe35 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:26:04 +0100 Subject: [PATCH 11/15] feat(threshold): split/combine reshare broadcast commitments into wire-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. --- .../wire-mesh-threshold-wasm/src/lib.rs | 29 ++++++++++++ .../crates/wire-mesh-threshold/src/reshare.rs | 47 +++++++++++++++++++ .../core/src/adapters/threshold-wasm.ts | 23 +++++++++ .../core/test/threshold-wasm.unit.test.ts | 20 ++++++++ 4 files changed, 119 insertions(+) diff --git a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs index 067247d..7f03f30 100644 --- a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs +++ b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs @@ -580,6 +580,35 @@ pub fn reshare_round1( }) } +/// Splits a survivor's serialized broadcast commitment (`reshare_round1`'s +/// own `commitment` output, a whole-blob serialization) into +/// `threshold-keygen-round1`'s own wire shape: an array of independently- +/// serialized coefficients (`commitment: [* bstr]`). The inverse of +/// [`reshare_combine_commitment_parts`]. +#[wasm_bindgen] +pub fn reshare_split_commitment(commitment: Vec) -> Result { + let commitment = + VerifiableSecretSharingCommitment::deserialize_whole(&commitment).map_err(js_err)?; + let parts = wire_mesh_threshold::reshare::split_commitment(&commitment).map_err(js_err)?; + let out = Array::new(); + for part in &parts { + out.push(&Uint8Array::from(part.as_slice())); + } + Ok(out) +} + +/// Reconstructs a survivor's serialized broadcast commitment (the same +/// whole-blob shape [`reshare_combine_commitments`] expects each entry of +/// its own `commitments` array to be) from the wire's own per-coefficient +/// array. The inverse of [`reshare_split_commitment`]. +#[wasm_bindgen] +pub fn reshare_combine_commitment_parts(parts: Array) -> Result, JsValue> { + let part_bytes = array_to_bytes_vec(&parts)?; + let commitment = + wire_mesh_threshold::reshare::combine_commitment_parts(&part_bytes).map_err(js_err)?; + commitment.serialize_whole().map_err(js_err) +} + #[wasm_bindgen] pub fn reshare_combine_commitments(commitments: Array) -> Result, JsValue> { let bytes = array_to_bytes_vec(&commitments)?; diff --git a/rust/crates/wire-mesh-threshold/src/reshare.rs b/rust/crates/wire-mesh-threshold/src/reshare.rs index 5efbbe9..2522724 100644 --- a/rust/crates/wire-mesh-threshold/src/reshare.rs +++ b/rust/crates/wire-mesh-threshold/src/reshare.rs @@ -217,6 +217,26 @@ pub fn combine_survivor_commitments( )?) } +/// Splits a survivor's broadcast commitment into `threshold-keygen-round1`'s +/// own wire shape: an array of independently-serialized coefficients +/// (`commitment: [* bstr]`) -- unlike `round1_reshare`'s own internal +/// whole-blob representation. The inverse of [`combine_commitment_parts`]. +pub fn split_commitment( + commitment: &VerifiableSecretSharingCommitment, +) -> Result>, ReshareError> { + Ok(commitment.serialize()?) +} + +/// Reconstructs a survivor's broadcast commitment (the same whole-blob +/// representation [`round1_reshare`]/[`combine_survivor_commitments`]/ +/// [`derive_public_key_package`] all use internally) from the wire's own +/// per-coefficient array. The inverse of [`split_commitment`]. +pub fn combine_commitment_parts( + parts: &[Vec], +) -> Result { + Ok(VerifiableSecretSharingCommitment::deserialize(parts)?) +} + /// The group's derived public key given the T survivors' combined broadcast /// commitment and the NEW participant set -- reused directly by /// `threshold-keygen-confirm`'s own `group-key` check (MUST equal @@ -452,4 +472,31 @@ mod tests { assert!(new_pkp.verifying_shares().get(&dropped).is_none()); let _ = dropped_old_key_package; } + + /// `threshold-keygen-round1`'s own wire shape carries the reshare + /// broadcast commitment as an array of independently-serialized + /// coefficients (`commitment: [* bstr]`), unlike `round1_reshare`'s own + /// internal whole-blob representation (`serialize_whole()`) -- split + /// then combine must round-trip to the identical commitment + /// `combine_survivor_commitments`/`derive_public_key_package` verify + /// against. + #[test] + fn split_and_combine_reshare_commitment_round_trips() { + let (old_ids, key_packages, _pkp) = dkg_fixture(); + let survivors = &old_ids[0..2]; + let kp = key_packages.get(&survivors[0]).expect("key package"); + let (commitment, _shares) = + round1_reshare(survivors[0], kp.signing_share(), survivors, survivors, 2) + .expect("round1_reshare"); + + let parts = split_commitment(&commitment).expect("split"); + assert!(!parts.is_empty()); + let recombined = combine_commitment_parts(&parts).expect("combine"); + assert_eq!(recombined, commitment); + } + + #[test] + fn combine_commitment_parts_rejects_malformed_bytes() { + assert!(combine_commitment_parts(&[vec![0u8; 4]]).is_err()); + } } diff --git a/ts/packages/core/src/adapters/threshold-wasm.ts b/ts/packages/core/src/adapters/threshold-wasm.ts index 52b6315..16c9257 100644 --- a/ts/packages/core/src/adapters/threshold-wasm.ts +++ b/ts/packages/core/src/adapters/threshold-wasm.ts @@ -295,6 +295,29 @@ export function reshareRound1( }; } +/** Splits a survivor's serialized broadcast commitment (reshareRound1's own `commitment` output) into `threshold-keygen-round1`'s own wire shape: an array of independently-serialized coefficients (`commitment: [* bstr]`) -- unlike reshareRound1's own whole-blob serialization. The inverse of reshareCombineCommitmentParts. */ +export function reshareSplitCommitment( + commitment: Uint8Array, +): Uint8Array[] { + const out: Uint8Array[] = []; + for (const part of wasm.reshare_split_commitment(commitment)) { + if (!isUint8Array(part)) { + throw new Error( + "wasm returned a non-Uint8Array entry in reshare_split_commitment's own output array", + ); + } + out.push(toBufferSource(part)); + } + return out; +} + +/** Reconstructs a survivor's serialized broadcast commitment (the same whole-blob shape reshareCombineCommitments expects each entry of its own commitments array to be) from the wire's own per-coefficient array. The inverse of reshareSplitCommitment. */ +export function reshareCombineCommitmentParts( + parts: readonly Uint8Array[], +): Uint8Array { + return toBufferSource(wasm.reshare_combine_commitment_parts([...parts])); +} + /** Sums T survivors' broadcast commitment vectors into the one combined VSS commitment `reshareDerivePublicKeyPackage` needs. */ export function reshareCombineCommitments( commitments: readonly Uint8Array[], diff --git a/ts/packages/core/test/threshold-wasm.unit.test.ts b/ts/packages/core/test/threshold-wasm.unit.test.ts index d897ad4..cbde41e 100644 --- a/ts/packages/core/test/threshold-wasm.unit.test.ts +++ b/ts/packages/core/test/threshold-wasm.unit.test.ts @@ -9,10 +9,12 @@ import { dkgTranscriptDigest, keyPackageSigningShare, splitRound1Package, + reshareCombineCommitmentParts, reshareCombineCommitments, reshareCombineReceivedShares, reshareDerivePublicKeyPackage, reshareRound1, + reshareSplitCommitment, signingAggregate, signingBuildPackage, signingRound1Commit, @@ -294,6 +296,24 @@ describe("threshold-wasm: signing", () => { }); describe("threshold-wasm: reshare", () => { + it("reshareSplitCommitment/reshareCombineCommitmentParts round-trips into the identical combined blob reshareCombineCommitments expects", () => { + const ids = [deviceId(1), deviceId(2), deviceId(THIRD_DEVICE_BYTE)]; + const [alice, bob] = twoSigners(runDkg(ids)); + const survivors: DeviceId[] = [alice.deviceId, bob.deviceId]; + const r1 = reshareRound1( + alice.deviceId, + keyPackageSigningShare(alice.round3.keyPackage), + survivors, + survivors, + THRESHOLD, + ); + + const parts = reshareSplitCommitment(r1.commitment); + expect(parts.length).toBeGreaterThan(0); + const recombined = reshareCombineCommitmentParts(parts); + expect(recombined).toEqual(r1.commitment); + }); + it("dropping a device preserves the group key and the survivors can still sign with it", async () => { const ids = [deviceId(1), deviceId(2), deviceId(THIRD_DEVICE_BYTE)]; const [alice, bob] = twoSigners(runDkg(ids)); From a0f0e2e0eef6cdb194c7c70d83f6c779d4f99f04 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:28:25 +0100 Subject: [PATCH 12/15] feat(threshold): expose the group verifying key from reshareDerivePublicKeyPackage 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. --- .../wire-mesh-threshold-wasm/src/lib.rs | 31 +++++++++++++++++-- .../core/src/adapters/threshold-wasm.ts | 21 ++++++++----- .../core/test/threshold-wasm.unit.test.ts | 7 ++--- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs index 7f03f30..1e71ec7 100644 --- a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs +++ b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs @@ -621,11 +621,35 @@ pub fn reshare_combine_commitments(commitments: Array) -> Result, JsValu combined.serialize_whole().map_err(js_err) } +#[wasm_bindgen] +pub struct ReshareDerivePublicKeyPackageOutput { + public_key_package: Vec, + group_verifying_key: Vec, +} + +#[wasm_bindgen] +impl ReshareDerivePublicKeyPackageOutput { + #[wasm_bindgen(getter, js_name = publicKeyPackage)] + pub fn public_key_package(&self) -> Vec { + self.public_key_package.clone() + } + + #[wasm_bindgen(getter, js_name = groupVerifyingKey)] + pub fn group_verifying_key(&self) -> Vec { + self.group_verifying_key.clone() + } +} + +/// `groupVerifyingKey` is what `threshold-keygen-confirm`'s own `group-key` +/// field carries and what a reshare's own verifier obligation checks +/// against `existing-group-key` -- exposed alongside the whole +/// `PublicKeyPackage` blob (mirroring `DkgRound3Output`'s identical shape +/// for fresh DKG) rather than requiring a second call to extract it. #[wasm_bindgen] pub fn reshare_derive_public_key_package( combined_commitment: Vec, new_participant_device_ids: Array, -) -> Result, JsValue> { +) -> Result { let commitment = VerifiableSecretSharingCommitment::deserialize_whole(&combined_commitment) .map_err(js_err)?; let bytes = array_to_bytes_vec(&new_participant_device_ids)?; @@ -635,7 +659,10 @@ pub fn reshare_derive_public_key_package( .collect::, _>>()?; let pkp = wire_mesh_threshold::reshare::derive_public_key_package(&commitment, &ids) .map_err(js_err)?; - pkp.serialize().map_err(js_err) + Ok(ReshareDerivePublicKeyPackageOutput { + public_key_package: pkp.serialize().map_err(js_err)?, + group_verifying_key: pkp.verifying_key().serialize().map_err(js_err)?, + }) } #[wasm_bindgen] diff --git a/ts/packages/core/src/adapters/threshold-wasm.ts b/ts/packages/core/src/adapters/threshold-wasm.ts index 16c9257..a4c7eba 100644 --- a/ts/packages/core/src/adapters/threshold-wasm.ts +++ b/ts/packages/core/src/adapters/threshold-wasm.ts @@ -325,16 +325,23 @@ export function reshareCombineCommitments( return toBufferSource(wasm.reshare_combine_commitments([...commitments])); } -/** The group's derived public key given the combined commitment and the new participant set -- reused directly by `threshold-keygen-confirm`'s own group-key check (MUST equal the group's `existing-group-key` for a reshare). */ +export interface ReshareDerivePublicKeyPackageResult { + publicKeyPackage: Uint8Array; + groupVerifyingKey: Uint8Array; +} + +/** The group's derived public key package given the combined commitment and the new participant set. `groupVerifyingKey` is what `threshold-keygen-confirm`'s own `group-key` field carries and what a reshare's own verifier obligation checks against `existing-group-key` (MUST equal it -- a reshare that changes the group key is a takeover, not a reshare). */ export function reshareDerivePublicKeyPackage( combinedCommitment: Uint8Array, newParticipantDeviceIds: readonly DeviceId[], -): Uint8Array { - return toBufferSource( - wasm.reshare_derive_public_key_package(combinedCommitment, [ - ...newParticipantDeviceIds, - ]), - ); +): ReshareDerivePublicKeyPackageResult { + const out = wasm.reshare_derive_public_key_package(combinedCommitment, [ + ...newParticipantDeviceIds, + ]); + return { + publicKeyPackage: toBufferSource(out.publicKeyPackage), + groupVerifyingKey: toBufferSource(out.groupVerifyingKey), + }; } /** New-participant side (local): verifies each received share against its own embedded commitment, sums the validated shares, and builds this participant's final key package. */ diff --git a/ts/packages/core/test/threshold-wasm.unit.test.ts b/ts/packages/core/test/threshold-wasm.unit.test.ts index cbde41e..590036b 100644 --- a/ts/packages/core/test/threshold-wasm.unit.test.ts +++ b/ts/packages/core/test/threshold-wasm.unit.test.ts @@ -340,10 +340,9 @@ describe("threshold-wasm: reshare", () => { const combined = reshareCombineCommitments( reshareR1.map((r) => r.commitment), ); - const newPublicKeyPackage = reshareDerivePublicKeyPackage( - combined, - survivors, - ); + const { publicKeyPackage: newPublicKeyPackage, groupVerifyingKey } = + reshareDerivePublicKeyPackage(combined, survivors); + expect(groupVerifyingKey).toEqual(originalGroupKey); const receivedByRecipient = new Map( survivors.map((id) => [Buffer.from(id).toString("hex"), []]), From 1184f82fd7e8a981ab44a780da096d552682c9d8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:39:12 +0100 Subject: [PATCH 13/15] feat(threshold): add a reshare-specific echo-broadcast transcript digest 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. --- .../wire-mesh-threshold-wasm/src/lib.rs | 23 +++++ .../crates/wire-mesh-threshold/src/reshare.rs | 88 ++++++++++++++++++- .../core/src/adapters/threshold-wasm.ts | 11 +++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs index 1e71ec7..6f4740f 100644 --- a/rust/crates/wire-mesh-threshold-wasm/src/lib.rs +++ b/rust/crates/wire-mesh-threshold-wasm/src/lib.rs @@ -621,6 +621,29 @@ pub fn reshare_combine_commitments(commitments: Array) -> Result, JsValu combined.serialize_whole().map_err(js_err) } +/// The echo-broadcast transcript digest a member of the new participant set +/// sends on `threshold-keygen-confirm` for a reshare -- the reshare analogue +/// of `dkg_transcript_digest`, structurally distinct because a reshare's own +/// survivor commitment (`survivor_commitments`' own entries, each the same +/// whole-blob shape `reshare_round1`'s own `commitment` output is) carries +/// no proof-of-knowledge component: deserializing one AS a DKG round-1 +/// `Package` fails outright, since the two are different wire shapes +/// entirely. +#[wasm_bindgen] +pub fn reshare_transcript_digest( + survivor_ids: Array, + survivor_commitments: Array, + group_verifying_key: Vec, +) -> Result, JsValue> { + let commitments = parallel_arrays_to_map(&survivor_ids, &survivor_commitments, |bytes| { + VerifiableSecretSharingCommitment::deserialize_whole(bytes).map_err(js_err) + })?; + let group_key = VerifyingKey::deserialize(&group_verifying_key).map_err(js_err)?; + let digest = wire_mesh_threshold::reshare::transcript_digest(&commitments, &group_key) + .map_err(js_err)?; + Ok(digest.to_vec()) +} + #[wasm_bindgen] pub struct ReshareDerivePublicKeyPackageOutput { public_key_package: Vec, diff --git a/rust/crates/wire-mesh-threshold/src/reshare.rs b/rust/crates/wire-mesh-threshold/src/reshare.rs index 2522724..31370d9 100644 --- a/rust/crates/wire-mesh-threshold/src/reshare.rs +++ b/rust/crates/wire-mesh-threshold/src/reshare.rs @@ -36,8 +36,9 @@ use frost_ed25519::keys::{ IdentifierList, KeyPackage, PublicKeyPackage, SecretShare, SigningShare, VerifiableSecretSharingCommitment, }; -use frost_ed25519::{Ed25519Sha512, Identifier, SigningKey}; +use frost_ed25519::{Ed25519Sha512, Identifier, SigningKey, VerifyingKey}; use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; type Ed25519CoefficientCommitment = CoefficientCommitment; @@ -237,6 +238,29 @@ pub fn combine_commitment_parts( Ok(VerifiableSecretSharingCommitment::deserialize(parts)?) } +/// The echo-broadcast transcript digest a member of the new participant set +/// sends on `threshold-keygen-confirm` for a reshare: `SHA-256` over the +/// full ordered (by identifier) set of every SURVIVOR's own broadcast +/// commitment, followed by the derived group verifying key -- the reshare +/// analogue of [`crate::dkg::transcript_digest`], structurally distinct +/// because a reshare's own survivor commitment carries no +/// proof-of-knowledge component to fold in (see `spec/threshold.cddl`'s own +/// comment on why one isn't needed here). `BTreeMap`'s own iteration order +/// is already sorted by key (`Identifier`'s `Ord` impl), so this is +/// deterministic across participants with no separate sort step. +pub fn transcript_digest( + survivor_commitments: &BTreeMap, + group_key: &VerifyingKey, +) -> Result<[u8; 32], ReshareError> { + let mut hasher = Sha256::new(); + for (id, commitment) in survivor_commitments { + hasher.update(id.serialize()); + hasher.update(commitment.serialize_whole()?); + } + hasher.update(group_key.serialize()?); + Ok(hasher.finalize().into()) +} + /// The group's derived public key given the T survivors' combined broadcast /// commitment and the NEW participant set -- reused directly by /// `threshold-keygen-confirm`'s own `group-key` check (MUST equal @@ -499,4 +523,66 @@ mod tests { fn combine_commitment_parts_rejects_malformed_bytes() { assert!(combine_commitment_parts(&[vec![0u8; 4]]).is_err()); } + + /// The echo-broadcast confirm round needs a transcript digest over the + /// T survivors' own reshare commitments -- structurally distinct from + /// [`crate::dkg::transcript_digest`]'s own full round1 `Package` + /// (commitment + proof-of-knowledge), which a reshare's `commitment` + /// simply isn't: attempting to deserialize a reshare commitment AS a + /// DKG Package is exactly the "Error deserializing value" bug this + /// function exists to avoid. + #[test] + fn two_participants_computing_over_the_identical_survivor_view_agree_on_the_same_digest() { + let (old_ids, key_packages, _pkp) = dkg_fixture(); + let survivors = &old_ids[0..2]; + let mut commitments = Map::new(); + for &survivor in survivors { + let kp = key_packages.get(&survivor).expect("key package"); + let (commitment, _shares) = + round1_reshare(survivor, kp.signing_share(), survivors, survivors, 2) + .expect("round1_reshare"); + commitments.insert(survivor, commitment); + } + let combined_for_key = + combine_survivor_commitments(&commitments.values().cloned().collect::>()) + .expect("combine"); + let new_pkp = derive_public_key_package(&combined_for_key, survivors).expect("derive pkp"); + + let digest_a = transcript_digest(&commitments, new_pkp.verifying_key()).expect("digest a"); + let digest_b = transcript_digest(&commitments, new_pkp.verifying_key()).expect("digest b"); + assert_eq!(digest_a, digest_b); + } + + #[test] + fn an_equivocated_survivor_view_produces_a_different_digest() { + let (old_ids, key_packages, _pkp) = dkg_fixture(); + let survivors = &old_ids[0..2]; + let kp_a = key_packages.get(&survivors[0]).expect("key package a"); + let kp_b = key_packages.get(&survivors[1]).expect("key package b"); + let (commitment_a, _) = + round1_reshare(survivors[0], kp_a.signing_share(), survivors, survivors, 2) + .expect("round1_reshare a"); + let (commitment_b, _) = + round1_reshare(survivors[1], kp_b.signing_share(), survivors, survivors, 2) + .expect("round1_reshare b"); + let (tampered_commitment_a, _) = + round1_reshare(survivors[0], kp_a.signing_share(), survivors, survivors, 2) + .expect("round1_reshare a again"); + + let mut honest = Map::new(); + honest.insert(survivors[0], commitment_a); + honest.insert(survivors[1], commitment_b.clone()); + let mut equivocated = Map::new(); + equivocated.insert(survivors[0], tampered_commitment_a); + equivocated.insert(survivors[1], commitment_b); + + let combined = combine_survivor_commitments(&honest.values().cloned().collect::>()) + .expect("combine"); + let pkp = derive_public_key_package(&combined, survivors).expect("derive pkp"); + + let honest_digest = transcript_digest(&honest, pkp.verifying_key()).expect("digest"); + let equivocated_digest = + transcript_digest(&equivocated, pkp.verifying_key()).expect("digest"); + assert_ne!(honest_digest, equivocated_digest); + } } diff --git a/ts/packages/core/src/adapters/threshold-wasm.ts b/ts/packages/core/src/adapters/threshold-wasm.ts index a4c7eba..6681a84 100644 --- a/ts/packages/core/src/adapters/threshold-wasm.ts +++ b/ts/packages/core/src/adapters/threshold-wasm.ts @@ -325,6 +325,17 @@ export function reshareCombineCommitments( return toBufferSource(wasm.reshare_combine_commitments([...commitments])); } +/** The echo-broadcast transcript digest a member of the new participant set sends on threshold-keygen-confirm for a reshare -- the reshare analogue of dkgTranscriptDigest, structurally distinct because a reshare's own survivor commitment carries no proof-of-knowledge component: deserializing one as a DKG round-1 package fails outright, since the two are different wire shapes entirely. `survivorCommitments` entries are each the same whole-blob shape reshareRound1's own `commitment` output is (NOT the wire's own split [* bstr] parts -- combine those first via reshareCombineCommitmentParts if collected off the wire). */ +export function reshareTranscriptDigest( + survivorCommitments: readonly DeviceKeyed[], + groupVerifyingKey: Uint8Array, +): Uint8Array { + const [ids, commitments] = toParallelArrays(survivorCommitments); + return toBufferSource( + wasm.reshare_transcript_digest(ids, commitments, groupVerifyingKey), + ); +} + export interface ReshareDerivePublicKeyPackageResult { publicKeyPackage: Uint8Array; groupVerifyingKey: Uint8Array; From 816284d28611eb76cd479a48ae9ffbb50a97b0c9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:39:20 +0100 Subject: [PATCH 14/15] feat(core): orchestrate reshare over real manage-request traffic 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. --- .../core/src/adapters/threshold-dkg.ts | 270 ++++++++++++++++++ .../threshold-reshare.integration.test.ts | 265 +++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 ts/packages/core/test/threshold-reshare.integration.test.ts diff --git a/ts/packages/core/src/adapters/threshold-dkg.ts b/ts/packages/core/src/adapters/threshold-dkg.ts index 3414452..7367201 100644 --- a/ts/packages/core/src/adapters/threshold-dkg.ts +++ b/ts/packages/core/src/adapters/threshold-dkg.ts @@ -10,6 +10,7 @@ import type { } from "../generated/protocol.js"; import { deviceIdToHex } from "../domain/device-id.js"; import type { MeshSession } from "../domain/mesh-session.js"; +import { bytesEqual } from "../domain/token-scope.js"; import { buildKeygenConfirmCommand, buildKeygenRound1Command, @@ -27,6 +28,14 @@ import { dkgRound2, dkgRound3, dkgTranscriptDigest, + keyPackageSigningShare, + reshareCombineCommitmentParts, + reshareCombineCommitments, + reshareCombineReceivedShares, + reshareDerivePublicKeyPackage, + reshareRound1, + reshareSplitCommitment, + reshareTranscriptDigest, splitRound1Package, type DeviceKeyed, } from "./threshold-wasm.js"; @@ -298,3 +307,264 @@ export async function runFreshThresholdDkg( return round3; } + +// --- Reshare ------------------------------------------------------------- +// +// Resharing has no single symmetric choreography the way fresh DKG does: only the T SURVIVORS dealt from an existing share broadcast round 1 and send round 2; every member of the NEW participant set (survivors staying on and brand-new joiners alike) must independently collect all T survivors' round1 broadcasts, derive the combined public key package, and combine whatever round2 shares it received. A device that is both a survivor AND a member of the new set runs BOTH contributeThresholdReshare and joinThresholdReshare concurrently; a survivor that is leaving runs only contributeThresholdReshare (and is done -- it has no new share, no confirm round to take part in); a brand-new device with no prior share runs only joinThresholdReshare. + +export interface ContributeThresholdReshareOptions { + session: Readonly; + ownDeviceId: DeviceId; + /** This survivor's own EXISTING key package for the group being reshared -- reshareRound1 needs only its signing-share bytes (keyPackageSigningShare), never the whole package. */ + ownOldKeyPackage: Uint8Array; + /** The full T-survivor set, INCLUDING this device. MUST be identical across every survivor's own call for this session-id. */ + survivors: readonly DeviceId[]; + /** The full new participant set this reshare is moving to (may overlap with survivors, may add or drop devices). MUST be identical across every survivor's own call for this session-id. */ + newParticipants: readonly DeviceId[]; + newThreshold: number; + /** The group's own existing Ed25519 verifying key, echoed on threshold-keygen-round1 as `existing-group-key` so recipients know this is a reshare (not a fresh DKG) of a SPECIFIC, already-known group. */ + existingGroupKey: Uint8Array; + sessionId: bigint; + scope?: Readonly; + token?: CapabilityToken; + timeoutMs?: number; +} + +export interface ReshareContribution { + commitment: Uint8Array; + outgoing: DeviceKeyed[]; + /** This survivor's own round-2 share to itself, present iff this device is also a member of newParticipants -- extracted from `outgoing` so a caller staying in the committee never has to special-case its own entry. */ + shareToSelf?: Uint8Array; +} + +/** + * The synchronous half of a surviving participant's dealer role: computes its Lagrange-weighted resharing of `ownOldKeyPackage`'s signing share across `newParticipants`. No network I/O -- deliberately split out from `sendReshareContribution` so a device that is ALSO staying in `newParticipants` can start `joinThresholdReshare` (which begins listening immediately) with this result's own `commitment`/`shareToSelf` BEFORE the broadcast sends below have gone anywhere, rather than after: two devices that are both survivors and both new participants would otherwise deadlock, each waiting for the other's `joinThresholdReshare` consume loop to start before either's own send can be acknowledged, while neither starts listening until its own sends finish. + */ +export function computeReshareContribution( + options: Readonly< + Pick< + ContributeThresholdReshareOptions, + | "ownDeviceId" + | "ownOldKeyPackage" + | "survivors" + | "newParticipants" + | "newThreshold" + > + >, +): ReshareContribution { + const ownOldShare = keyPackageSigningShare(options.ownOldKeyPackage); + const r1 = reshareRound1( + options.ownDeviceId, + ownOldShare, + options.survivors, + options.newParticipants, + options.newThreshold, + ); + const shareToSelf = r1.outgoing.find((entry) => + bytesEqual(entry.deviceId, options.ownDeviceId), + )?.value; + return { + commitment: r1.commitment, + outgoing: r1.outgoing, + ...(shareToSelf !== undefined ? { shareToSelf } : {}), + }; +} + +/** + * The network half of a surviving participant's dealer role: broadcasts round 1 (the commitment) to every recipient other than itself, then sends each recipient's own round-2 share pairwise, from an already-computed `computeReshareContribution` result. Resolves once every send has been acknowledged. + */ +export async function sendReshareContribution( + options: Readonly, + contribution: Readonly, +): Promise { + const scope = options.scope ?? THRESHOLD_GROUP_SCOPE; + const commitmentParts = reshareSplitCommitment(contribution.commitment); + + const recipients = options.newParticipants.filter( + (peer) => !bytesEqual(peer, options.ownDeviceId), + ); + await Promise.all( + recipients.map(async (peer) => { + const command = buildKeygenRound1Command( + options.sessionId, + options.newThreshold, + options.newParticipants, + commitmentParts, + { existingGroupKey: options.existingGroupKey }, + ); + await options.session.sendManageRequest( + command, + scope, + peer, + options.token, + options.timeoutMs, + ); + }), + ); + + await Promise.all( + contribution.outgoing.map(async ({ deviceId: peer, value: share }) => { + if (bytesEqual(peer, options.ownDeviceId)) { + return; + } + const command = buildKeygenRound2Command(options.sessionId, share, true); + await options.session.sendManageRequest( + command, + scope, + peer, + options.token, + options.timeoutMs, + ); + }), + ); +} + +/** + * Runs a surviving participant's OWN dealer role end to end: `computeReshareContribution` then `sendReshareContribution`. Only correct for a survivor that is LEAVING the committee (not a member of `newParticipants`) -- it has no further round to take part in, so there is no concurrent listener this call's own sends could deadlock against. A survivor that is ALSO staying in `newParticipants` MUST call `computeReshareContribution` and `sendReshareContribution` separately, starting its own `joinThresholdReshare` (with the computed contribution) concurrently with `sendReshareContribution` -- see that function's own doc comment for why. + */ +export async function contributeThresholdReshare( + options: Readonly, +): Promise { + const contribution = computeReshareContribution(options); + await sendReshareContribution(options, contribution); + return contribution; +} + +export interface ReshareOwnContribution { + /** This device's own survivor commitment (contributeThresholdReshare's own return value's `commitment` field), when this device is itself one of the T survivors. */ + commitment: Uint8Array; + /** This device's own round-2 share-to-self (contributeThresholdReshare's own return value's `shareToSelf` field). */ + shareToSelf: Uint8Array; +} + +export interface JoinThresholdReshareOptions { + session: Readonly; + ownDeviceId: DeviceId; + /** Every survivor this device must collect a round1 broadcast and round2 share FROM over the wire -- the full survivor set, MINUS this device itself if it is also a survivor (see ownContribution). */ + otherSurvivors: readonly DeviceId[]; + /** Present when this device is itself one of the T survivors (its own contribution is known locally, from a concurrent contributeThresholdReshare call on this same device, never sent to itself over the wire); absent for a brand-new device with no prior share, which only ever receives. */ + ownContribution?: Readonly; + newParticipants: readonly DeviceId[]; + newThreshold: number; + existingGroupKey: Uint8Array; + sessionId: bigint; + scope?: Readonly; + token?: CapabilityToken; + timeoutMs?: number; +} + +/** + * Runs a new-participant's own receiving role: collects every survivor's round1 broadcast and this device's own round2 share, derives the reshared group's public key package (verifying it still matches `existingGroupKey` -- a reshare that changes the group key is a takeover, never adopted), then confirms via the same echo-broadcast digest exchange fresh DKG uses, against every OTHER member of `newParticipants` (not just survivors -- every new-participant peer independently derives and must agree on the identical digest and group key). Rejects, without ever returning a key package, on any digest, group-key, or existing-group-key mismatch. + */ +export async function joinThresholdReshare( + options: Readonly, +): Promise { + const verb = keygenCapabilityVerb(true); + const scope = options.scope ?? THRESHOLD_GROUP_SCOPE; + const collectors: DkgCollectors = { + round1: new PerDeviceCollector(), + round2: new PerDeviceCollector(), + confirm: new PerDeviceCollector(), + }; + startDkgConsumeLoop(options.session, verb, options.sessionId, collectors); + + const otherSurvivorEntries: DeviceKeyed[] = await Promise.all( + options.otherSurvivors.map(async (peer): Promise => { + const payload = await collectors.round1.awaitFrom(deviceIdToHex(peer)); + return { + deviceId: peer, + value: reshareCombineCommitmentParts(payload.commitment), + }; + }), + ); + const survivorEntries: DeviceKeyed[] = + options.ownContribution !== undefined + ? [ + { + deviceId: options.ownDeviceId, + value: Uint8Array.from(options.ownContribution.commitment), + }, + ...otherSurvivorEntries, + ] + : otherSurvivorEntries; + + const combined = reshareCombineCommitments( + survivorEntries.map((entry) => entry.value), + ); + const derived = reshareDerivePublicKeyPackage( + combined, + options.newParticipants, + ); + if (!bytesEqual(derived.groupVerifyingKey, options.existingGroupKey)) { + throw new Error( + "reshare's own derived group key does not match existing-group-key -- refusing to adopt a takeover", + ); + } + + const receivedShares: Uint8Array[] = await Promise.all( + options.otherSurvivors.map(async (peer) => + collectors.round2.awaitFrom(deviceIdToHex(peer)), + ), + ); + if (options.ownContribution !== undefined) { + receivedShares.push(options.ownContribution.shareToSelf); + } + + const keyPackage = reshareCombineReceivedShares( + options.ownDeviceId, + receivedShares, + derived.publicKeyPackage, + options.newThreshold, + ); + + const otherNewParticipants = options.newParticipants.filter( + (peer) => !bytesEqual(peer, options.ownDeviceId), + ); + const ownDigest = reshareTranscriptDigest( + survivorEntries, + derived.groupVerifyingKey, + ); + await Promise.all( + otherNewParticipants.map(async (peer) => { + const command = buildKeygenConfirmCommand( + options.sessionId, + ownDigest, + derived.groupVerifyingKey, + true, + ); + await options.session.sendManageRequest( + command, + scope, + peer, + options.token, + options.timeoutMs, + ); + }), + ); + await Promise.all( + otherNewParticipants.map(async (peer) => { + const confirm = await collectors.confirm.awaitFrom(deviceIdToHex(peer)); + const matches = dkgConfirmMatches( + ownDigest, + derived.groupVerifyingKey, + confirm.transcriptDigest, + confirm.groupKey, + ); + if (!matches) { + const error = new Error( + `reshare echo-broadcast transcript mismatch with ${deviceIdToHex(peer)} -- aborting`, + ); + collectors.round1.fail(error); + collectors.round2.fail(error); + collectors.confirm.fail(error); + throw error; + } + }), + ); + + return { + keyPackage, + publicKeyPackage: derived.publicKeyPackage, + groupVerifyingKey: derived.groupVerifyingKey, + }; +} diff --git a/ts/packages/core/test/threshold-reshare.integration.test.ts b/ts/packages/core/test/threshold-reshare.integration.test.ts new file mode 100644 index 0000000..1887c13 --- /dev/null +++ b/ts/packages/core/test/threshold-reshare.integration.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { + computeReshareContribution, + joinThresholdReshare, + runFreshThresholdDkg, + sendReshareContribution, +} from "../src/adapters/threshold-dkg.js"; +import { deviceIdToHex } from "../src/domain/device-id.js"; +import type { + CapabilityScope, + DeviceId, + ManageCommand, +} from "../src/generated/protocol.js"; +import type { + IncomingManageRequest, + ManageOutcome, + MeshSession, +} from "../src/domain/mesh-session.js"; +import { + signingAggregate, + signingBuildPackage, + signingRound1Commit, + signingRound2Sign, + type DeviceKeyed, +} from "../src/adapters/threshold-wasm.js"; +import { verifyWithPublicKey } from "../src/adapters/node-identity.js"; + +const DEVICE_ID_LENGTH = 32; +const THRESHOLD = 2; +const SECOND_DEVICE_BYTE = 2; +const THIRD_DEVICE_BYTE = 3; +const FOURTH_DEVICE_BYTE = 4; +const DKG_SESSION_ID = 1n; +const RESHARE_SESSION_ID = 2n; +const ALG_ED25519 = -8; + +function deviceId(byte: number): DeviceId { + const bytes = new Uint8Array(DEVICE_ID_LENGTH); + bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; +} + +/** The same relay-shaped in-process bus threshold-dkg.integration.test.ts uses, kept local for the identical reason: real fromDevice-carrying manage-request round trips, no real transport underneath. */ +function createRelayBus(): { + sessionFor: ( + device: DeviceId, + ) => Pick; +} { + interface Inbox { + waiters: ((request: IncomingManageRequest) => void)[]; + backlog: IncomingManageRequest[]; + } + const inboxes = new Map(); + let nextRequestId = 0; + + function inboxFor(hex: string): Inbox { + let inbox = inboxes.get(hex); + if (inbox === undefined) { + inbox = { waiters: [], backlog: [] }; + inboxes.set(hex, inbox); + } + return inbox; + } + + return { + sessionFor: (self: DeviceId) => ({ + sendManageRequest: async ( + command: ManageCommand, + scope: Readonly, + targetDevice?: DeviceId, + ): Promise => { + if (targetDevice === undefined) { + throw new Error("test bus requires an explicit targetDevice"); + } + const requestId = nextRequestId; + nextRequestId += 1; + return new Promise((resolve) => { + const incoming: IncomingManageRequest = { + requestId, + command, + scope, + fromDevice: self, + respond: async (outcome: ManageOutcome): Promise => { + resolve(outcome); + return Promise.resolve(); + }, + }; + const inbox = inboxFor(deviceIdToHex(targetDevice)); + const waiter = inbox.waiters.shift(); + if (waiter) { + waiter(incoming); + } else { + inbox.backlog.push(incoming); + } + }); + }, + incomingManageRequests: { + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => + new Promise((resolve) => { + const inbox = inboxFor(deviceIdToHex(self)); + const backlogItem = inbox.backlog.shift(); + if (backlogItem) { + resolve({ value: backlogItem, done: false }); + } else { + inbox.waiters.push((request) => { + resolve({ value: request, done: false }); + }); + } + }), + }; + }, + }, + }), + }; +} + +describe("threshold-dkg: contributeThresholdReshare + joinThresholdReshare", () => { + it("reshares a T=2-of-3 group to a different T=2-of-3 committee (one device dropped, one added), preserving the group key and producing shares that actually sign", async () => { + const alice = deviceId(1); + const bob = deviceId(SECOND_DEVICE_BYTE); + const carol = deviceId(THIRD_DEVICE_BYTE); // leaving + const dave = deviceId(FOURTH_DEVICE_BYTE); // joining + + const dkgBus = createRelayBus(); + // carol's own DKG result is intentionally unused past this point -- she is the survivor who leaves in the reshare below and never runs joinThresholdReshare. + const [aliceDkg, bobDkg] = await Promise.all([ + runFreshThresholdDkg({ + session: dkgBus.sessionFor(alice), + ownDeviceId: alice, + otherParticipants: [bob, carol], + threshold: THRESHOLD, + sessionId: DKG_SESSION_ID, + }), + runFreshThresholdDkg({ + session: dkgBus.sessionFor(bob), + ownDeviceId: bob, + otherParticipants: [alice, carol], + threshold: THRESHOLD, + sessionId: DKG_SESSION_ID, + }), + runFreshThresholdDkg({ + session: dkgBus.sessionFor(carol), + ownDeviceId: carol, + otherParticipants: [alice, bob], + threshold: THRESHOLD, + sessionId: DKG_SESSION_ID, + }), + ]); + const originalGroupKey = aliceDkg.groupVerifyingKey; + + // Reshare: alice and bob (survivors) hand off to alice, bob, dave (carol drops, dave joins) -- still T=2. + const survivors = [alice, bob]; + const newParticipants = [alice, bob, dave]; + const reshareBus = createRelayBus(); + + // A survivor that is ALSO staying in newParticipants must start its own joinThresholdReshare (which begins listening immediately) CONCURRENTLY with sendReshareContribution's own broadcast sends -- awaiting the sends to finish before starting to listen would deadlock against every other survivor doing the identical thing, each waiting for the other's consume loop to start before its own send can be acknowledged. Only computeReshareContribution (synchronous, no I/O) is a real dependency joinThresholdReshare needs before it can start. + async function survivorFlow( + self: DeviceId, + ownOldKeyPackage: Uint8Array, + otherSurvivors: readonly DeviceId[], + ) { + const baseOptions = { + session: reshareBus.sessionFor(self), + ownDeviceId: self, + survivors, + newParticipants, + newThreshold: THRESHOLD, + existingGroupKey: originalGroupKey, + sessionId: RESHARE_SESSION_ID, + }; + const contribution = computeReshareContribution({ + ...baseOptions, + ownOldKeyPackage, + }); + const [, joinResult] = await Promise.all([ + sendReshareContribution( + { ...baseOptions, ownOldKeyPackage }, + contribution, + ), + joinThresholdReshare({ + ...baseOptions, + otherSurvivors, + ...(contribution.shareToSelf !== undefined + ? { + ownContribution: { + commitment: contribution.commitment, + shareToSelf: contribution.shareToSelf, + }, + } + : {}), + }), + ]); + return joinResult; + } + + const [aliceResult, bobResult, daveResult] = await Promise.all([ + survivorFlow(alice, aliceDkg.keyPackage, [bob]).catch((e: unknown) => { + console.error("ALICE FLOW FAILED", e); + throw e; + }), + survivorFlow(bob, bobDkg.keyPackage, [alice]).catch((e: unknown) => { + console.error("BOB FLOW FAILED", e); + throw e; + }), + joinThresholdReshare({ + session: reshareBus.sessionFor(dave), + ownDeviceId: dave, + otherSurvivors: survivors, + newParticipants, + newThreshold: THRESHOLD, + existingGroupKey: originalGroupKey, + sessionId: RESHARE_SESSION_ID, + }).catch((e: unknown) => { + console.error("DAVE FLOW FAILED", e); + throw e; + }), + ]); + + // Carol never ran joinThresholdReshare -- she is not in newParticipants and correctly has no result. + expect(aliceResult.groupVerifyingKey).toEqual(originalGroupKey); + expect(bobResult.groupVerifyingKey).toEqual(originalGroupKey); + expect(daveResult.groupVerifyingKey).toEqual(originalGroupKey); + + // T=2 of Bob/Dave (neither of whom held a share together before the reshare) actually sign, and the aggregate verifies against the SAME group key with ordinary Web Crypto. + const message = new TextEncoder().encode("reshare produced real shares"); + const bobCommit = signingRound1Commit(bobResult.keyPackage); + const daveCommit = signingRound1Commit(daveResult.keyPackage); + const commitEntries: DeviceKeyed[] = [ + { deviceId: bob, value: bobCommit.commitments }, + { deviceId: dave, value: daveCommit.commitments }, + ]; + const signingPackage = signingBuildPackage(commitEntries, message); + const shares: DeviceKeyed[] = [ + { + deviceId: bob, + value: signingRound2Sign( + bobCommit.nonces, + signingPackage, + bobResult.keyPackage, + ), + }, + { + deviceId: dave, + value: signingRound2Sign( + daveCommit.nonces, + signingPackage, + daveResult.keyPackage, + ), + }, + ]; + const signature = signingAggregate( + signingPackage, + shares, + bobResult.publicKeyPackage, + ); + const ok = await verifyWithPublicKey( + { alg: ALG_ED25519, "public-key": originalGroupKey }, + message, + signature, + ); + expect(ok).toBe(true); + }); +}); From 2db68ae9a4ffba74e96b96f988797a06223e733a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:47:47 +0100 Subject: [PATCH 15/15] feat(threshold): wire the Rust coordinator and participant handlers to 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. --- rust/crates/wire-mesh-threshold/src/lib.rs | 1 + .../crates/wire-mesh-threshold/src/network.rs | 691 ++++++++++++++++++ 2 files changed, 692 insertions(+) create mode 100644 rust/crates/wire-mesh-threshold/src/network.rs diff --git a/rust/crates/wire-mesh-threshold/src/lib.rs b/rust/crates/wire-mesh-threshold/src/lib.rs index e04173f..17449fc 100644 --- a/rust/crates/wire-mesh-threshold/src/lib.rs +++ b/rust/crates/wire-mesh-threshold/src/lib.rs @@ -4,6 +4,7 @@ pub mod dkg; pub mod identifiers; pub mod identity; pub mod lagrange; +pub mod network; pub mod nonce_store; pub mod reshare; pub mod share_envelope; diff --git a/rust/crates/wire-mesh-threshold/src/network.rs b/rust/crates/wire-mesh-threshold/src/network.rs new file mode 100644 index 0000000..6218bb2 --- /dev/null +++ b/rust/crates/wire-mesh-threshold/src/network.rs @@ -0,0 +1,691 @@ +//! Real network integration for both roles of `exadev.io/threshold:sign` +//! (wire-mesh#171): [`NetworkThresholdCoordinator`] drives +//! [`crate::identity::ThresholdCoordinator`]'s own `commit_round`/ +//! `sign_round` over actual `manage-request`/`manage-response` traffic via +//! a caller-supplied [`ManageRequestSender`], and [`handle_threshold_commit`]/ +//! [`handle_threshold_sign`]/[`handle_threshold_abort`] are the participant- +//! side handlers a real dispatch loop calls once it has already decoded a +//! `ManageParams::Threshold*` variant (`wire-mesh-wire::threshold`, this +//! issue's own Rust wire codec). +//! +//! This crate has no transport dependency of its own (see this crate's own +//! `Cargo.toml` comment on why `wire-mesh-core`'s `net` feature stays off, +//! required for `wire-mesh-threshold-wasm`'s `wasm32-unknown-unknown` build +//! to succeed at all) -- [`ManageRequestSender`] is the transport-agnostic +//! contract a real binary's own TCP/relay-routed session implements, +//! mirroring how `mesh-session.ts`'s `sendManageRequest`/ +//! `incomingManageRequests` play the identical role on the TypeScript side +//! (`ts/packages/core/src/adapters/threshold-network-coordinator.ts` and +//! `threshold-participant.ts`). Unlike TypeScript, this codebase has no +//! live Rust node/session runtime at all yet (no equivalent of +//! `mesh-session.ts`'s own incoming-request dispatch loop) -- these handlers +//! are the building blocks such a runtime would call, not a runnable loop +//! in their own right. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use frost_ed25519::keys::KeyPackage; +use frost_ed25519::round1::SigningCommitments; +use frost_ed25519::round2::SignatureShare; +use frost_ed25519::Identifier; +use wire_mesh_core::ports::identity::Identity; +use wire_mesh_core::ports::CoreError; +use wire_mesh_wire::identity::DeviceId; +use wire_mesh_wire::management::{ManageCommand, ManageOutcome, ManageParams}; +use wire_mesh_wire::threshold::{ + ThresholdCommit, ThresholdCommitment, ThresholdSign, ThresholdSubject as WireThresholdSubject, +}; +use wire_mesh_wire::tokens::{CapabilityScope, CapabilityVerb, CoseSign1}; +use wire_mesh_wire::value::CborValue; + +use crate::identifiers::identifier_for_device; +use crate::identity::ThresholdCoordinator; +use crate::nonce_store::{NonceStore, SessionId}; +use crate::share_envelope::verify_share_envelope; +use crate::signing::{combine_commitments, round1_commit, round2_respond, split_commitments}; +use crate::subject::{refuse_unrecognised_kind, to_be_signed, SubjectDecision, ThresholdSubject}; + +/// Gates `threshold.commit`/`.sign`/`.abort` -- matches +/// `ts/packages/core/src/domain/threshold-network.ts`'s own +/// `THRESHOLD_SIGN_VERB` exactly. +pub const THRESHOLD_SIGN_VERB: &str = "exadev.io/threshold:sign"; + +fn threshold_group_scope() -> CapabilityScope { + CapabilityScope { + kind: "group".to_owned(), + path: None, + } +} + +fn wire_subject(subject: &ThresholdSubject) -> WireThresholdSubject { + WireThresholdSubject { + kind: subject.kind.clone(), + protected: subject.protected.clone(), + payload: subject.payload.clone(), + } +} + +fn domain_subject(subject: &WireThresholdSubject) -> ThresholdSubject { + ThresholdSubject { + kind: subject.kind.clone(), + protected: subject.protected.clone(), + payload: subject.payload.clone(), + } +} + +/// The transport-agnostic contract a real `manage-request`/`manage-response` +/// session implements -- never assumes TCP, a specific framing, or a +/// retry/timeout policy, matching this project's async-contracts +/// convention. A second implementation on a completely different transport +/// satisfies this exact contract with zero changes to the contract or its +/// callers. +#[async_trait::async_trait] +pub trait ManageRequestSender: Send + Sync { + async fn send_manage_request( + &self, + target: DeviceId, + command: ManageCommand, + scope: CapabilityScope, + token: Option, + ) -> Result; +} + +fn bytes_field<'a>( + extra: &'a wire_mesh_wire::value::CanonicalMap, + key: &str, +) -> Option<&'a [u8]> { + match extra.get(&key.to_owned()) { + Some(CborValue::Bytes(bytes)) => Some(bytes), + _ => None, + } +} + +fn device_id_field( + extra: &wire_mesh_wire::value::CanonicalMap, + key: &str, +) -> Option { + let bytes = bytes_field(extra, key)?; + let arr: [u8; 32] = bytes.try_into().ok()?; + Some(DeviceId::from_bytes(arr)) +} + +/// Drives the two-round FROST signing protocol over a real +/// [`ManageRequestSender`], exactly as `core/webrtc`'s own negotiation rides +/// `manage-request-frame`. `group`/`verifier_identity`/`token` are fixed for +/// this coordinator's whole lifetime (a coordinator instance is scoped to +/// one group), matching [`ThresholdCoordinator`]'s own contract, which +/// deliberately carries no group/verifier parameters of its own. +/// +/// [`ThresholdCoordinator::sign_round`] receives commitments keyed by FROST +/// `Identifier`, not `DeviceId` -- deriving one from the other is one-way, so +/// this coordinator caches the `Identifier -> DeviceId` mapping `commit_round` +/// already has, per session-id, consumed (and removed) by the matching +/// `sign_round` call. This relies on `commit_round` being called once +/// immediately before `sign_round` for a given session, exactly +/// `ThresholdIdentity::sign_subject`'s own usage pattern. +pub struct NetworkThresholdCoordinator { + sender: S, + group: DeviceId, + scope: CapabilityScope, + token: Option, + verifier_identity: Arc, + identifier_lookup: Mutex>>, +} + +impl NetworkThresholdCoordinator { + pub fn new(sender: S, group: DeviceId, verifier_identity: Arc) -> Self { + Self { + sender, + group, + scope: threshold_group_scope(), + token: None, + verifier_identity, + identifier_lookup: Mutex::new(HashMap::new()), + } + } + + #[must_use] + pub fn with_token(mut self, token: CoseSign1) -> Self { + self.token = Some(token); + self + } +} + +#[async_trait::async_trait] +impl ThresholdCoordinator for NetworkThresholdCoordinator { + async fn commit_round( + &self, + session_id: u64, + participants: &[DeviceId], + subject: &ThresholdSubject, + deadline_unix_ms: u64, + ) -> Result, CoreError> { + let wire_subject = wire_subject(subject); + let mut identifier_map = HashMap::new(); + let mut results = Vec::new(); + for &participant in participants { + let command = ManageCommand { + verb: CapabilityVerb(THRESHOLD_SIGN_VERB.to_owned()), + params: ManageParams::ThresholdCommit(ThresholdCommit { + session_id, + group: self.group, + subject: wire_subject.clone(), + deadline: deadline_unix_ms, + }), + }; + let outcome = self + .sender + .send_manage_request(participant, command, self.scope.clone(), self.token.clone()) + .await?; + let ManageOutcome::Ok(ok) = outcome else { + continue; + }; + let (Some(responder), Some(hiding), Some(binding)) = ( + device_id_field(&ok.extra, "participant"), + bytes_field(&ok.extra, "hiding"), + bytes_field(&ok.extra, "binding"), + ) else { + continue; + }; + if responder != participant { + continue; + } + let Ok(commitments) = combine_commitments(hiding, binding) else { + continue; + }; + let Ok(identifier) = identifier_for_device(&participant) else { + continue; + }; + identifier_map.insert(identifier, participant); + results.push((identifier, commitments)); + } + if !identifier_map.is_empty() { + self.identifier_lookup + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session_id, identifier_map); + } + Ok(results) + } + + async fn sign_round( + &self, + session_id: u64, + commitments: &[(Identifier, SigningCommitments)], + ) -> Result, CoreError> { + let identifier_map = { + let mut lock = self + .identifier_lookup + .lock() + .unwrap_or_else(|e| e.into_inner()); + lock.remove(&session_id) + } + .ok_or_else(|| { + CoreError::Crypto( + "sign_round called for a session with no prior commit_round".to_owned(), + ) + })?; + + let mut wire_commitments = Vec::with_capacity(commitments.len()); + for (identifier, signing_commitments) in commitments { + let device_id = *identifier_map + .get(identifier) + .ok_or_else(|| CoreError::Crypto("unknown identifier in commitments".to_owned()))?; + let (hiding, binding) = split_commitments(signing_commitments) + .map_err(|e| CoreError::Crypto(e.to_string()))?; + wire_commitments.push(ThresholdCommitment { + participant: device_id, + hiding, + binding, + }); + } + + let mut results = Vec::new(); + for (identifier, _) in commitments { + let device_id = *identifier_map + .get(identifier) + .ok_or_else(|| CoreError::Crypto("unknown identifier in commitments".to_owned()))?; + let command = ManageCommand { + verb: CapabilityVerb(THRESHOLD_SIGN_VERB.to_owned()), + params: ManageParams::ThresholdSign(ThresholdSign { + session_id, + commitments: wire_commitments.clone(), + }), + }; + let outcome = self + .sender + .send_manage_request(device_id, command, self.scope.clone(), self.token.clone()) + .await?; + let ManageOutcome::Ok(ok) = outcome else { + continue; + }; + let Some(share_bytes) = bytes_field(&ok.extra, "share") else { + continue; + }; + let Ok(envelope) = CoseSign1::decode_bytes(share_bytes) else { + continue; + }; + let Ok(claims) = + verify_share_envelope(self.verifier_identity.as_ref(), &envelope).await + else { + continue; + }; + if claims.session_id != session_id + || claims.group != self.group + || claims.issuer != device_id + { + continue; + } + let Ok(share) = SignatureShare::deserialize(&claims.share) else { + continue; + }; + results.push((*identifier, share)); + } + Ok(results) + } +} + +/// An error handling an incoming threshold.commit/.sign/.abort request -- +/// the participant-side counterpart to [`NetworkThresholdCoordinator`]'s own +/// `CoreError` returns. Every variant maps to a `manage-error` a real +/// dispatch loop sends back; none of them panic or silently swallow. +#[derive(Debug)] +pub enum ParticipantError { + DeadlinePassed, + UnknownGroup, + Refused(String), + NoSuchSession, + NonceUnavailable, + Crypto(String), +} + +impl core::fmt::Display for ParticipantError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ParticipantError::DeadlinePassed => write!(f, "deadline-passed"), + ParticipantError::UnknownGroup => write!(f, "unknown-group"), + ParticipantError::Refused(reason) => write!(f, "refused: {reason}"), + ParticipantError::NoSuchSession => write!(f, "no-such-session"), + ParticipantError::NonceUnavailable => write!(f, "nonce-unavailable"), + ParticipantError::Crypto(e) => write!(f, "crypto: {e}"), + } + } +} + +impl std::error::Error for ParticipantError {} + +/// `threshold.commit`'s own manage-ok extension: `participant`/`hiding`/ +/// `binding`, matching `spec/threshold.cddl`'s own comment on that verb. +pub struct CommitResponse { + pub participant: DeviceId, + pub hiding: Vec, + pub binding: Vec, +} + +/// A signing session's own state a caller must persist between round 1 and +/// round 2, keyed by session-id -- round 2 carries no subject of its own +/// (`spec/threshold.cddl`'s own structural closure against a coordinator +/// showing different participants different content across the two rounds), +/// so the message this participant is actually signing must be recalled +/// from its own round-1 state, never re-derived from round 2's own wire +/// bytes. +pub struct SigningSessionState { + pub message: Vec, +} + +/// Round-1 handling: runs `refuse_unrecognised_kind` (always first, +/// regardless of what `authorise` returns) then `authorise` against +/// `params.subject`, and -- only if both pass -- computes this +/// participant's own commitment. Returning a commitment IS this +/// participant's act of authorisation. `own_device_id` is this +/// participant's own device-id -- `KeyPackage` carries only a FROST +/// `Identifier`, and deriving a device-id back out of one is one-way, so the +/// caller (which already knows its own identity) supplies it directly +/// rather than this function trying to recover it. `key_package_of_group` +/// resolves this participant's own FROST key package for `params.group`, +/// `None` meaning this participant holds no share in that group at all. +pub fn handle_threshold_commit( + params: &ThresholdCommit, + own_device_id: DeviceId, + key_package_of_group: impl Fn(&DeviceId) -> Option, + nonce_store: &dyn NonceStore, + now_unix_ms: u64, + authorise: impl Fn(&ThresholdSubject) -> Option, +) -> Result<(CommitResponse, SigningSessionState), ParticipantError> { + if params.deadline <= now_unix_ms { + return Err(ParticipantError::DeadlinePassed); + } + let key_package = key_package_of_group(¶ms.group).ok_or(ParticipantError::UnknownGroup)?; + let subject = domain_subject(¶ms.subject); + let decision = refuse_unrecognised_kind(&subject, crate::subject::KNOWN_KINDS) + .unwrap_or_else(|| authorise(&subject).unwrap_or(SubjectDecision::Authorise)); + if let SubjectDecision::Refuse { reason } = decision { + return Err(ParticipantError::Refused(reason)); + } + + let message = to_be_signed(&subject); + let session_id: SessionId = params.session_id; + let commitments = round1_commit(nonce_store, session_id, &key_package) + .map_err(|e| ParticipantError::Crypto(e.to_string()))?; + let (hiding, binding) = + split_commitments(&commitments).map_err(|e| ParticipantError::Crypto(e.to_string()))?; + + Ok(( + CommitResponse { + participant: own_device_id, + hiding, + binding, + }, + SigningSessionState { message }, + )) +} + +/// Round-2 handling: takes (one-shot) this session's own persisted nonce and +/// `state.message` (recalled from round 1, never re-derived from +/// `params.commitments`), releases this participant's signature share, and +/// mints a real `threshold-share-envelope` signed under `personal_identity` +/// -- never the group's own key. +pub async fn handle_threshold_sign( + params: &ThresholdSign, + state: &SigningSessionState, + key_package: &KeyPackage, + nonce_store: &dyn NonceStore, + personal_identity: &dyn Identity, + group: DeviceId, +) -> Result { + let session_id: SessionId = params.session_id; + let mut commitments_map = std::collections::BTreeMap::new(); + for entry in ¶ms.commitments { + let identifier = identifier_for_device(&entry.participant) + .map_err(|e| ParticipantError::Crypto(e.to_string()))?; + let combined = combine_commitments(&entry.hiding, &entry.binding) + .map_err(|e| ParticipantError::Crypto(e.to_string()))?; + commitments_map.insert(identifier, combined); + } + let signing_package = crate::signing::build_signing_package(commitments_map, &state.message); + round2_respond( + nonce_store, + session_id, + &signing_package, + key_package, + personal_identity, + group, + ) + .await + .map_err(|_| ParticipantError::NonceUnavailable) +} + +/// Discards this session's own persisted nonce and any recalled state -- +/// the `deadline` expiry path and an explicit `threshold.abort` both call +/// this, never `round2_respond`, since neither actually produces a +/// signature. +pub fn handle_threshold_abort(session_id: u64, nonce_store: &dyn NonceStore) { + let _ = nonce_store.discard(session_id); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dkg::{round1 as dkg_round1, round2 as dkg_round2, round3 as dkg_round3}; + use crate::identifiers::identifier_for_device; + use crate::nonce_store::InMemoryNonceStore; + use crate::subject::ThresholdSubject as DomainThresholdSubject; + use frost_ed25519::keys::PublicKeyPackage; + use std::collections::BTreeMap as Map; + use std::sync::Mutex as StdMutex; + use wire_mesh_core::adapters::node_identity::NodeIdentity; + use wire_mesh_wire::value::CanonicalMap; + + /// A fresh T=2-of-3 DKG purely as test fixture setup -- identical in + /// shape to `signing.rs`'s own `dkg_fixture`, kept local for the same + /// stated reason: this module's own tests build a real coordinator + /// directly on top of it. + /// `device_ids` MUST be each intended signer's own REAL personal + /// device-id (e.g. a `NodeIdentity`'s), never a synthetic placeholder -- + /// the FROST `Identifier` this fixture derives for each participant + /// must match the identifier a real wire round trip later derives from + /// that same participant's `threshold-commitment.participant` field, or + /// `frost_ed25519::round2::sign` rejects the signing package outright + /// ("must contain the participant's Commitment"). + fn dkg_fixture( + device_ids: &[DeviceId], + ) -> ( + Vec, + Map, + DeviceId, + PublicKeyPackage, + ) { + let ids: Vec = device_ids + .iter() + .map(|d| identifier_for_device(d).expect("derives")) + .collect(); + + let mut secrets1 = Map::new(); + let mut packages1: Map = Map::new(); + for &id in &ids { + let (s, p) = dkg_round1(id, ids.len() as u16, 2).expect("round1"); + secrets1.insert(id, s); + packages1.insert(id, p); + } + + let mut round2_inbox: Map< + Identifier, + Map, + > = Map::new(); + let mut secrets2 = Map::new(); + for &id in &ids { + let own = secrets1.remove(&id).expect("secret1"); + let others: Map<_, _> = packages1 + .iter() + .filter(|(&p, _)| p != id) + .map(|(&p, v)| (p, v.clone())) + .collect(); + let (s2, outgoing) = dkg_round2(own, &others).expect("round2"); + secrets2.insert(id, s2); + for (recipient, package) in outgoing { + round2_inbox + .entry(recipient) + .or_default() + .insert(id, package); + } + } + + let mut key_packages = Map::new(); + let mut group_device_id = None; + let mut group_public_key_package = None; + for &id in &ids { + let s2 = secrets2.get(&id).expect("secret2"); + let others1: Map<_, _> = packages1 + .iter() + .filter(|(&p, _)| p != id) + .map(|(&p, v)| (p, v.clone())) + .collect(); + let inbox = round2_inbox.get(&id).expect("inbox"); + let (kp, pkp) = dkg_round3(s2, &others1, inbox).expect("round3"); + group_device_id = Some( + wire_mesh_core::adapters::node_identity::derive_device_id_from_public_key( + &pkp.verifying_key().serialize().expect("serialize"), + ), + ); + group_public_key_package = Some(pkp); + key_packages.insert(id, kp); + } + + ( + ids, + key_packages, + group_device_id.expect("at least one participant"), + group_public_key_package.expect("at least one participant"), + ) + } + + struct FakeParticipant { + device_id: DeviceId, + key_package: KeyPackage, + identity: NodeIdentity, + nonce_store: InMemoryNonceStore, + session_state: StdMutex>, + } + + struct FakeSender { + group: DeviceId, + participants: Vec, + } + + fn manage_ok(fields: Vec<(&str, Vec)>) -> ManageOutcome { + let mut extra = CanonicalMap::new(); + for (key, value) in fields { + extra + .insert(key.to_owned(), CborValue::Bytes(value)) + .expect("unique key"); + } + ManageOutcome::Ok(wire_mesh_wire::management::ManageOk { extra }) + } + + fn manage_error(code: &str) -> ManageOutcome { + ManageOutcome::Error(wire_mesh_wire::management::ManageError { + code: code.to_owned(), + message: None, + }) + } + + #[async_trait::async_trait] + impl ManageRequestSender for FakeSender { + async fn send_manage_request( + &self, + target: DeviceId, + command: ManageCommand, + _scope: CapabilityScope, + _token: Option, + ) -> Result { + let participant = self + .participants + .iter() + .find(|p| p.device_id == target) + .expect("test fixture: unknown target device"); + match command.params { + ManageParams::ThresholdCommit(params) => { + let result = handle_threshold_commit( + ¶ms, + target, + |g| { + if *g == self.group { + Some(participant.key_package.clone()) + } else { + None + } + }, + &participant.nonce_store, + 0, + |_subject: &DomainThresholdSubject| None, + ); + match result { + Ok((response, state)) => { + participant + .session_state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(params.session_id, state); + Ok(manage_ok(vec![ + ("participant", response.participant.as_ref().to_vec()), + ("hiding", response.hiding), + ("binding", response.binding), + ])) + } + Err(e) => Ok(manage_error(&e.to_string())), + } + } + ManageParams::ThresholdSign(params) => { + let state = participant + .session_state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(¶ms.session_id); + let Some(state) = state else { + return Ok(manage_error("no-such-session")); + }; + let result = handle_threshold_sign( + ¶ms, + &state, + &participant.key_package, + &participant.nonce_store, + &participant.identity, + self.group, + ) + .await; + match result { + Ok(envelope) => Ok(manage_ok(vec![("share", envelope.encode_to_vec())])), + Err(e) => Ok(manage_error(&e.to_string())), + } + } + _ => Ok(manage_error("unsupported")), + } + } + } + + #[tokio::test] + async fn commit_and_sign_round_over_a_fake_sender_produce_a_verifying_signature() { + // Personal identities are generated FIRST, and the DKG fixture is built around their own real device-ids -- see dkg_fixture's own doc comment for why a synthetic placeholder device-id would silently derive the wrong FROST identifier. + let personal_identities: Vec = + (0..2).map(|_| NodeIdentity::generate_ed25519()).collect(); + let device_ids: Vec = personal_identities + .iter() + .map(|identity| *identity.device_id()) + .collect(); + + let (ids, key_packages, group, public_key_package) = dkg_fixture(&device_ids); + let signers = ids.clone(); + + let mut participants = Vec::new(); + for (index, identity) in personal_identities.into_iter().enumerate() { + let device_id = device_ids[index]; + let id = signers[index]; + participants.push(FakeParticipant { + device_id, + key_package: key_packages.get(&id).expect("key package").clone(), + identity, + nonce_store: InMemoryNonceStore::new(), + session_state: StdMutex::new(HashMap::new()), + }); + } + + let sender = FakeSender { + group, + participants, + }; + let verifier_identity: Arc = Arc::new(NodeIdentity::generate_ed25519()); + let coordinator = NetworkThresholdCoordinator::new(sender, group, verifier_identity); + + let subject = DomainThresholdSubject { + kind: "capability-token".to_owned(), + protected: vec![0xa1, 0x01, 0x27], + payload: vec![0xa1, 0x00, 0x01], + }; + let message = crate::subject::to_be_signed(&subject); + + let commitments = coordinator + .commit_round(1, &device_ids, &subject, u64::MAX) + .await + .expect("commit_round"); + assert_eq!(commitments.len(), 2); + + let shares = coordinator + .sign_round(1, &commitments) + .await + .expect("sign_round"); + assert_eq!(shares.len(), 2); + + let commitments_map: Map<_, _> = commitments.into_iter().collect(); + let signing_package = crate::signing::build_signing_package(commitments_map, &message); + let shares_map: Map<_, _> = shares.into_iter().collect(); + + let signature = + crate::signing::aggregate(&signing_package, &shares_map, &public_key_package) + .expect("aggregate"); + assert!(public_key_package + .verifying_key() + .verify(&message, &signature) + .is_ok()); + } +}