From a00b10fe10240c870b96530535b1a9f6865542b4 Mon Sep 17 00:00:00 2001 From: Joe Wallace Date: Tue, 18 Aug 2026 13:17:08 -0700 Subject: [PATCH] feat(wasm-mps): interleave VRF-DKG into ed25519 DKG via with_vrf Safe creation advances signing DKG and VRF-DKG in the same three rounds when round0 is called with with_vrf=true, and writes both finished shares into one RootDocument. Ordinary DKG (with_vrf=false) keeps the previous bare Keyshare encoding. Hard-derive takes that combined document; the standalone VRF-DKG wasm exports are removed. Co-authored-by: Cursor --- packages/wasm-mps/Cargo.lock | 32 ++ packages/wasm-mps/Cargo.toml | 2 +- packages/wasm-mps/src/lib.rs | 580 ++++++++++++++++++++++++++++---- packages/wasm-mps/test/mps.ts | 171 +++++++++- packages/wasm-mps/test/utils.ts | 77 +++++ 5 files changed, 794 insertions(+), 68 deletions(-) diff --git a/packages/wasm-mps/Cargo.lock b/packages/wasm-mps/Cargo.lock index d5bb526f2fb..19f7cfe453a 100644 --- a/packages/wasm-mps/Cargo.lock +++ b/packages/wasm-mps/Cargo.lock @@ -885,6 +885,7 @@ dependencies = [ "signature", "sl-mpc-derive", "sl-mpc-mate", + "sl-mpc-vrf", "thiserror 1.0.69", "zeroize", ] @@ -1338,6 +1339,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sl-mpc-vrf" +version = "0.1.0-pre.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78aa6ea9cad657a879d3cc8d11e1df100c5c53b764902ed7139fbae32425b72e" +dependencies = [ + "crypto-bigint", + "curve25519-dalek", + "elliptic-curve", + "ff", + "rand", + "rand_chacha", + "serde", + "serde_bytes", + "sha2", + "sl-mpc-derive", + "sl-mpc-mate", + "sl-transcript", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "sl-secret-sharing" version = "0.1.0-pre.1" @@ -1346,6 +1369,15 @@ dependencies = [ "ff", ] +[[package]] +name = "sl-transcript" +version = "0.1.0-pre.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33f0e5dbfa7ca28f40e1a16bb2763e468d10e6a3716f24d2ecf498d262832e3" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/packages/wasm-mps/Cargo.toml b/packages/wasm-mps/Cargo.toml index 75750826579..ee8c7b330ab 100644 --- a/packages/wasm-mps/Cargo.toml +++ b/packages/wasm-mps/Cargo.toml @@ -17,7 +17,7 @@ bincode = { package = "bincode-next", version = "3.1.1", features = ["serde"] } crypto_box = "0.9" getrandom = { version = "0.2", features = ["js"] } js-sys = "0.3" -multi-party-schnorr = { version = "1.3.0-pre.6", features = ["serde", "eddsa", "redpallas"] } +multi-party-schnorr = { version = "1.3.0-pre.6", features = ["serde", "eddsa", "redpallas", "vrf"] } orchard = { version = "0.13", default-features = false } zcash = { git = "https://github.com/silence-laboratories/garbling", rev = "268a8499bca4f1a1eea532117430fd6fd55310c8", features = ["dkg"] } pasta_curves = { version = "0.5", default-features = false } diff --git a/packages/wasm-mps/src/lib.rs b/packages/wasm-mps/src/lib.rs index 8d95005bbf6..85eecec854c 100644 --- a/packages/wasm-mps/src/lib.rs +++ b/packages/wasm-mps/src/lib.rs @@ -19,8 +19,16 @@ mod mps { PartialSign, SignError, SignReady, SignerParty, R0 as DsgR0, R1 as DsgR1, R2 as DsgR2, }, }; + use multi_party_schnorr::{ + derive::{with_ristretto_vrf_ed25519, HardDerivePartyEd25519, MpcDeriveInitEd25519}, + vrf::{ + dkg::Party as VrfParty, keyshare_after_hard_derive, HardDeriveMsg0, HardDeriveMsg1, + HardDeriveR0, HardDeriveR1, HardDeriveR2, VrfDkgParty, VrfDkgR0, VrfDkgR1, VrfDkgR2, + VrfKeygenMsg1, VrfKeygenMsg2, VrfPoint, + }, + }; use rand::Rng; - use serde::{Deserialize, Serialize}; + use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ io::{Cursor, Read}, sync::Arc, @@ -79,6 +87,31 @@ mod mps { pub party: KeygenParty, } + #[derive(Serialize, Deserialize)] + struct VrfDkgStateR1 { + party_id: u8, + msg: VrfKeygenMsg1, + party: VrfDkgParty, + } + + #[derive(Serialize, Deserialize)] + struct VrfDkgStateR2 { + party_id: u8, + msg: VrfKeygenMsg2, + party: VrfDkgParty, + } + + pub const ROOT_DOCUMENT_VERSION: u8 = 1; + + /// Combined root document produced by Ed25519 DKG when `with_vrf` is true. + /// Ordinary DKG (`with_vrf=false`) still encodes a bare `Keyshare`. + #[derive(Serialize, Deserialize)] + pub struct RootDocument { + pub version: u8, + pub signing: Keyshare, + pub vrf: Keyshare, + } + /// Internal DSG state used for round 1. #[derive(Serialize, Deserialize)] #[serde(bound( @@ -137,6 +170,8 @@ mod mps { pub share: Vec, pub pk: [u8; 32], pub chaincode: [u8; 32], + pub vrf_pk: Option<[u8; 32]>, + pub vrf_chaincode: Option<[u8; 32]>, } pub struct MsgDerivationInit { @@ -205,6 +240,85 @@ mod mps { result } + fn encode_head_tail( + head: &H, + tail: Option<&T>, + ) -> Result, MpsError> { + let mut buf = bincode::serde::encode_to_vec(head, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?; + if let Some(t) = tail { + buf.extend( + bincode::serde::encode_to_vec(t, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + ); + } + Ok(buf) + } + + fn decode_head_tail( + data: &[u8], + ) -> Result<(H, Option), MpsError> { + let (head, n) = bincode::serde::decode_from_slice(data, bincode::config::standard()) + .map_err(|_| MpsError::DeserializationError)?; + let rest = &data[n..]; + if rest.is_empty() { + return Ok((head, None)); + } + let (tail, n2) = bincode::serde::decode_from_slice(rest, bincode::config::standard()) + .map_err(|_| MpsError::DeserializationError)?; + if n2 != rest.len() { + return Err(MpsError::DeserializationError); + } + Ok((head, Some(tail))) + } + + pub fn encode_root_document_from_bytes( + signing_bytes: &[u8], + vrf_bytes: &[u8], + ) -> Result, MpsError> { + let (signing, n): (Keyshare, usize) = + bincode::serde::decode_from_slice(signing_bytes, bincode::config::standard()) + .map_err(|_| MpsError::DeserializationError)?; + if n != signing_bytes.len() { + return Err(MpsError::DeserializationError); + } + let (vrf, n): (Keyshare, usize) = + bincode::serde::decode_from_slice(vrf_bytes, bincode::config::standard()) + .map_err(|_| MpsError::DeserializationError)?; + if n != vrf_bytes.len() { + return Err(MpsError::DeserializationError); + } + encode_root_document(&signing, &vrf) + } + + pub fn encode_root_document( + signing: &Keyshare, + vrf: &Keyshare, + ) -> Result, MpsError> { + bincode::serde::encode_to_vec( + &RootDocument { + version: ROOT_DOCUMENT_VERSION, + signing: signing.clone(), + vrf: vrf.clone(), + }, + bincode::config::standard(), + ) + .map_err(|_| MpsError::SerializationError) + } + + pub fn decode_root_document(data: &[u8]) -> Result { + let (doc, n): (RootDocument, usize) = + bincode::serde::decode_from_slice(data, bincode::config::standard()) + .map_err(|_| MpsError::DeserializationError)?; + if n != data.len() { + return Err(MpsError::DeserializationError); + } + if doc.version != ROOT_DOCUMENT_VERSION { + return Err(MpsError::InvalidInput); + } + Ok(doc) + } + /// Serialize a message pool as a concatenation of individually-encoded messages. /// This format supports simple byte concatenation to merge pools. pub fn serialize_pool(prefix: &str, msgs: &[DrvMessage]) -> Result, MpsError> { @@ -244,6 +358,7 @@ mod mps { decryption_key: &[u8; 32], encryption_keys: &[Vec; 2], seed: &[u8; 32], + with_vrf: bool, ) -> Result where G: GroupElem, @@ -300,11 +415,24 @@ mod mps { party: p1, }; + let vrf_state = if with_vrf { + let mut rng = rand::thread_rng(); + let vrf_party = VrfParty::new(3, 2, party_id); + let vp0 = VrfDkgParty::::new(vrf_party, *seed, &mut rng) + .map_err(|_| MpsError::ProtocolError)?; + let (vp1, vrf_msg1) = vp0.process(()).map_err(|_| MpsError::ProtocolError)?; + Some(VrfDkgStateR1 { + party_id, + msg: vrf_msg1, + party: vp1, + }) + } else { + None + }; + Ok(MsgState { - msg: bincode::serde::encode_to_vec(msg1, bincode::config::standard()) - .map_err(|_| MpsError::SerializationError)?, - state: bincode::serde::encode_to_vec(&state, bincode::config::standard()) - .map_err(|_| MpsError::SerializationError)?, + msg: encode_head_tail(&state.msg, vrf_state.as_ref().map(|s| &s.msg))?, + state: encode_head_tail(&state, vrf_state.as_ref())?, }) } @@ -316,35 +444,39 @@ mod mps { G: GroupElem, G::Scalar: ScalarReduce<[u8; 32]> + Serializable, { - // Parse state - let state: DkgStateR1 = - bincode::serde::decode_from_slice(state, bincode::config::standard()) - .map(|(v, _)| v) - .map_err(|_| MpsError::DeserializationError)?; + let (state, vrf_state): (DkgStateR1, Option) = decode_head_tail(state)?; - // Parse messages - let i0_msg1: KeygenMsg1 = bincode::serde::decode_from_slice( - round1_messages[0].as_slice(), - bincode::config::standard(), - ) - .map(|(v, _)| v) - .map_err(|_| MpsError::DeserializationError)?; - let i1_msg1: KeygenMsg1 = bincode::serde::decode_from_slice( - round1_messages[1].as_slice(), - bincode::config::standard(), - ) - .map(|(v, _)| v) - .map_err(|_| MpsError::DeserializationError)?; - let msgs = vec![i0_msg1, i1_msg1, state.msg]; + let (i0_msg1, i0_vrf): (KeygenMsg1, Option) = + decode_head_tail(round1_messages[0].as_slice())?; + let (i1_msg1, i1_vrf): (KeygenMsg1, Option) = + decode_head_tail(round1_messages[1].as_slice())?; + + let with_vrf = vrf_state.is_some(); + if with_vrf != i0_vrf.is_some() || with_vrf != i1_vrf.is_some() { + return Err(MpsError::InvalidInput); + } - // Process all round0 messages together let party_id = state.party_id; + let msgs = vec![i0_msg1, i1_msg1, state.msg]; let (p2, msg2) = state .party .process(msgs) .map_err(|_| MpsError::ProtocolError)?; - // Create the state for storage between rounds + let next_vrf = if let Some(vrf_state) = vrf_state { + let (vp2, vrf_msg2) = vrf_state + .party + .process(vec![i0_vrf.unwrap(), i1_vrf.unwrap(), vrf_state.msg]) + .map_err(|_| MpsError::ProtocolError)?; + Some(VrfDkgStateR2 { + party_id, + msg: vrf_msg2, + party: vp2, + }) + } else { + None + }; + let state = DkgStateR2 { party_id, msg: msg2.clone(), @@ -352,50 +484,51 @@ mod mps { }; Ok(MsgState { - msg: bincode::serde::encode_to_vec(&msg2, bincode::config::standard()) - .map_err(|_| MpsError::SerializationError)?, - state: bincode::serde::encode_to_vec(&state, bincode::config::standard()) - .map_err(|_| MpsError::SerializationError)?, + msg: encode_head_tail(&msg2, next_vrf.as_ref().map(|s| &s.msg))?, + state: encode_head_tail(&state, next_vrf.as_ref())?, }) } + type DkgRound2Result = (Keyshare, u8, Option>); + fn internal_dkg_round2_process( round2_messages: &[Vec; 2], state: &[u8], - ) -> Result<(Keyshare, u8), MpsError> + ) -> Result, MpsError> where G: GroupElem, G::Scalar: ScalarReduce<[u8; 32]> + Serializable, { - // Deserialize round2 messages from other parties - let i0_msg2: KeygenMsg2 = bincode::serde::decode_from_slice( - round2_messages[0].as_slice(), - bincode::config::standard(), - ) - .map(|(v, _)| v) - .map_err(|_| MpsError::DeserializationError)?; - let i1_msg2: KeygenMsg2 = bincode::serde::decode_from_slice( - round2_messages[1].as_slice(), - bincode::config::standard(), - ) - .map(|(v, _)| v) - .map_err(|_| MpsError::DeserializationError)?; + let (i0_msg2, i0_vrf): (KeygenMsg2, Option) = + decode_head_tail(round2_messages[0].as_slice())?; + let (i1_msg2, i1_vrf): (KeygenMsg2, Option) = + decode_head_tail(round2_messages[1].as_slice())?; - // Deserialize state - let state: DkgStateR2 = - bincode::serde::decode_from_slice(state, bincode::config::standard()) - .map(|(v, _)| v) - .map_err(|_| MpsError::DeserializationError)?; + let (state, vrf_state): (DkgStateR2, Option) = decode_head_tail(state)?; - let party_id = state.party_id; + let with_vrf = vrf_state.is_some(); + if with_vrf != i0_vrf.is_some() || with_vrf != i1_vrf.is_some() { + return Err(MpsError::InvalidInput); + } - // Generate share + let party_id = state.party_id; let share = state .party .process(vec![i0_msg2, i1_msg2, state.msg]) .map_err(|_| MpsError::ProtocolError)?; - Ok((share, party_id)) + let vrf_share = if let Some(vrf_state) = vrf_state { + Some( + vrf_state + .party + .process(vec![i0_vrf.unwrap(), i1_vrf.unwrap(), vrf_state.msg]) + .map_err(|_| MpsError::ProtocolError)?, + ) + } else { + None + }; + + Ok((share, party_id, vrf_share)) } fn internal_dsg_round0_process(p0: SignerParty) -> Result @@ -540,12 +673,14 @@ mod mps { decryption_key: &[u8; 32], encryption_keys: &[Vec; 2], seed: &[u8; 32], + with_vrf: bool, ) -> Result { let result = internal_dkg_round0_process::( party_id, decryption_key, encryption_keys, seed, + with_vrf, )?; Ok(MsgState { msg: add_prefix("mps-ed25519-dkg-round1-message$", &result.msg), @@ -698,12 +833,28 @@ mod mps { let i0_msg2 = rem_prefix("mps-ed25519-dkg-round2-message$", &round2_messages[0])?; let i1_msg2 = rem_prefix("mps-ed25519-dkg-round2-message$", &round2_messages[1])?; let state = rem_prefix("mps-ed25519-dkg-round2-state$", state)?; - let (share, _) = internal_dkg_round2_process::(&[i0_msg2, i1_msg2], &state)?; + let (share, _, vrf_share) = + internal_dkg_round2_process::(&[i0_msg2, i1_msg2], &state)?; + let (share_bytes, vrf_pk, vrf_chaincode) = if let Some(vrf) = vrf_share { + ( + encode_root_document(&share, &vrf)?, + Some(vrf.public_key.compress().to_bytes()), + Some(vrf.root_chain_code), + ) + } else { + ( + bincode::serde::encode_to_vec(&share, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + None, + None, + ) + }; Ok(Share { - share: bincode::serde::encode_to_vec(&share, bincode::config::standard()) - .map_err(|_| MpsError::SerializationError)?, + share: share_bytes, pk: share.public_key.compress().to_bytes(), chaincode: share.root_chain_code, + vrf_pk, + vrf_chaincode, }) } @@ -793,6 +944,122 @@ mod mps { Ok(sig) } + // Hard derive (ed25519) — consumes a combined RootDocument (signing + VRF + // shares from with_vrf DKG). 2-of-3 threshold; round0 bootstraps alone, + // round1/round2 take a single peer message each. `path` is opaque bytes. + + #[derive(Serialize, Deserialize)] + struct HardDeriveStateR1Ed25519 { + msg: HardDeriveMsg0, + party: HardDerivePartyEd25519, + init: MpcDeriveInitEd25519, + } + + #[derive(Serialize, Deserialize)] + struct HardDeriveStateR2Ed25519 { + msg: HardDeriveMsg1, + party: HardDerivePartyEd25519, + init: MpcDeriveInitEd25519, + } + + pub fn ed25519_hard_derive_round0_process( + root_share: &[u8], + path: &[u8], + _seed: &[u8], + ) -> Result { + let doc = decode_root_document(root_share)?; + let init: MpcDeriveInitEd25519 = with_ristretto_vrf_ed25519(doc.signing, doc.vrf); + let mut rng = rand::thread_rng(); + let p0 = HardDerivePartyEd25519::::new(init.clone(), path.to_vec(), &mut rng) + .map_err(|_| MpsError::ProtocolError)?; + let (p1, msg0) = p0.process(()).map_err(|_| MpsError::ProtocolError)?; + let state = HardDeriveStateR1Ed25519 { + msg: msg0.clone(), + party: p1, + init, + }; + Ok(MsgState { + msg: add_prefix( + "mps-ed25519-hard-derive-round1-message$", + &bincode::serde::encode_to_vec(&msg0, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + ), + state: add_prefix( + "mps-ed25519-hard-derive-round1-state$", + &bincode::serde::encode_to_vec(&state, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + ), + }) + } + + pub fn ed25519_hard_derive_round1_process( + round1_message: &[u8], + state: &[u8], + ) -> Result { + let state = rem_prefix("mps-ed25519-hard-derive-round1-state$", state)?; + let state: HardDeriveStateR1Ed25519 = + bincode::serde::decode_from_slice(&state, bincode::config::standard()) + .map(|(v, _)| v) + .map_err(|_| MpsError::DeserializationError)?; + let round1_message = rem_prefix("mps-ed25519-hard-derive-round1-message$", round1_message)?; + let peer: HardDeriveMsg0 = + bincode::serde::decode_from_slice(&round1_message, bincode::config::standard()) + .map(|(v, _)| v) + .map_err(|_| MpsError::DeserializationError)?; + let (p2, msg1) = state + .party + .process(vec![peer, state.msg]) + .map_err(|_| MpsError::ProtocolError)?; + let new_state = HardDeriveStateR2Ed25519 { + msg: msg1.clone(), + party: p2, + init: state.init, + }; + Ok(MsgState { + msg: add_prefix( + "mps-ed25519-hard-derive-round2-message$", + &bincode::serde::encode_to_vec(&msg1, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + ), + state: add_prefix( + "mps-ed25519-hard-derive-round2-state$", + &bincode::serde::encode_to_vec(&new_state, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + ), + }) + } + + pub fn ed25519_hard_derive_round2_process( + round2_message: &[u8], + state: &[u8], + participating_party_ids: &[u8; 2], + ) -> Result { + let state = rem_prefix("mps-ed25519-hard-derive-round2-state$", state)?; + let state: HardDeriveStateR2Ed25519 = + bincode::serde::decode_from_slice(&state, bincode::config::standard()) + .map(|(v, _)| v) + .map_err(|_| MpsError::DeserializationError)?; + let round2_message = rem_prefix("mps-ed25519-hard-derive-round2-message$", round2_message)?; + let peer: HardDeriveMsg1 = + bincode::serde::decode_from_slice(&round2_message, bincode::config::standard()) + .map(|(v, _)| v) + .map_err(|_| MpsError::DeserializationError)?; + let output = state + .party + .process(vec![peer, state.msg]) + .map_err(|_| MpsError::ProtocolError)?; + let derived: Keyshare = + keyshare_after_hard_derive(&state.init, &output, participating_party_ids.as_slice()); + Ok(Share { + share: bincode::serde::encode_to_vec(&derived, bincode::config::standard()) + .map_err(|_| MpsError::SerializationError)?, + pk: derived.public_key.compress().to_bytes(), + chaincode: derived.root_chain_code, + vrf_pk: None, + vrf_chaincode: None, + }) + } + /// Process round 0 of RedPallas DKG (same flow as ed25519). pub fn redpallas_dkg_round0_process( party_id: u8, @@ -805,6 +1072,7 @@ mod mps { decryption_key, encryption_keys, seed, + false, )?; Ok(MsgState { msg: add_prefix("mps-redpallas-dkg-round1-message$", &result.msg), @@ -836,7 +1104,7 @@ mod mps { let i0_msg2 = rem_prefix("mps-redpallas-dkg-round2-message$", &round2_messages[0])?; let i1_msg2 = rem_prefix("mps-redpallas-dkg-round2-message$", &round2_messages[1])?; let state = rem_prefix("mps-redpallas-dkg-round2-state$", state)?; - let (share, party_id) = + let (share, party_id, _) = internal_dkg_round2_process::(&[i0_msg2, i1_msg2], &state)?; let pk = RedPallasPointBytes::from(share.public_key).0; let share_bytes = bincode::serde::encode_to_vec(&share, bincode::config::standard()) @@ -1071,6 +1339,7 @@ mod tests { pub_keys[2].1.to_bytes().to_vec(), ], &seeds[0], + false, ) .unwrap(); let p1_0 = mps::ed25519_dkg_round0_process( @@ -1081,6 +1350,7 @@ mod tests { pub_keys[2].1.to_bytes().to_vec(), ], &seeds[1], + false, ) .unwrap(); let p2_0 = mps::ed25519_dkg_round0_process( @@ -1091,6 +1361,7 @@ mod tests { pub_keys[1].1.to_bytes().to_vec(), ], &seeds[2], + false, ) .unwrap(); @@ -1258,6 +1529,7 @@ mod tests { pub_keys[2].1.to_bytes().to_vec(), ], &seeds[0], + false, ) .unwrap(); let dkg_p1_0 = mps::ed25519_dkg_round0_process( @@ -1268,6 +1540,7 @@ mod tests { pub_keys[2].1.to_bytes().to_vec(), ], &seeds[1], + false, ) .unwrap(); let dkg_p2_0 = mps::ed25519_dkg_round0_process( @@ -1278,6 +1551,7 @@ mod tests { pub_keys[1].1.to_bytes().to_vec(), ], &seeds[2], + false, ) .unwrap(); @@ -1500,6 +1774,113 @@ mod tests { rk.verify(msg, &sig) .expect("signature must verify against rk"); } + + #[test] + fn test_root_document_roundtrip_and_bare_keyshare() { + use multi_party_schnorr::{curve25519_dalek::EdwardsPoint, keygen::Keyshare}; + + let mut prv_keys = Vec::new(); + let mut pub_keys = Vec::new(); + let mut seeds = Vec::new(); + for i in 0..3 { + let secret_key = crypto_box::SecretKey::generate(&mut rand::thread_rng()); + let public_key = secret_key.public_key(); + prv_keys.push(secret_key); + pub_keys.push((i, public_key)); + let seed: [u8; 32] = rand::thread_rng().gen(); + seeds.push(seed); + } + let other = [[1usize, 2], [0, 2], [0, 1]]; + + let r0: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round0_process( + i as u8, + &prv_keys[i].to_bytes(), + &[ + pub_keys[other[i][0]].1.to_bytes().to_vec(), + pub_keys[other[i][1]].1.to_bytes().to_vec(), + ], + &seeds[i], + false, + ) + .unwrap() + }) + .collect(); + let r1: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round1_process( + &[r0[other[i][0]].msg.clone(), r0[other[i][1]].msg.clone()], + r0[i].state.as_slice(), + ) + .unwrap() + }) + .collect(); + let bare = mps::ed25519_dkg_round2_process( + &[r1[other[0][0]].msg.clone(), r1[other[0][1]].msg.clone()], + r1[0].state.as_slice(), + ) + .unwrap(); + + assert!(bare.vrf_pk.is_none()); + let (keyshare, n): (Keyshare, usize) = + bincode::serde::decode_from_slice(&bare.share, bincode::config::standard()).unwrap(); + assert_eq!(n, bare.share.len()); + assert!(mps::decode_root_document(&bare.share).is_err()); + assert_eq!(keyshare.public_key.compress().to_bytes(), bare.pk); + + let r0v: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round0_process( + i as u8, + &prv_keys[i].to_bytes(), + &[ + pub_keys[other[i][0]].1.to_bytes().to_vec(), + pub_keys[other[i][1]].1.to_bytes().to_vec(), + ], + &seeds[i], + true, + ) + .unwrap() + }) + .collect(); + let r1v: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round1_process( + &[r0v[other[i][0]].msg.clone(), r0v[other[i][1]].msg.clone()], + r0v[i].state.as_slice(), + ) + .unwrap() + }) + .collect(); + let shares: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round2_process( + &[r1v[other[i][0]].msg.clone(), r1v[other[i][1]].msg.clone()], + r1v[i].state.as_slice(), + ) + .unwrap() + }) + .collect(); + + assert_eq!(shares[0].pk, shares[1].pk); + assert_eq!(shares[1].pk, shares[2].pk); + assert_eq!(shares[0].chaincode, shares[2].chaincode); + assert_eq!(shares[0].vrf_pk, shares[1].vrf_pk); + assert_eq!(shares[1].vrf_pk, shares[2].vrf_pk); + assert!(shares[0].vrf_pk.is_some()); + + let doc = mps::decode_root_document(&shares[0].share).unwrap(); + assert_eq!(doc.version, mps::ROOT_DOCUMENT_VERSION); + let encoded = mps::encode_root_document(&doc.signing, &doc.vrf).unwrap(); + assert_eq!(encoded, shares[0].share); + + assert!(mps::ed25519_dkg_round1_process( + &[r0v[1].msg.clone(), r0[2].msg.clone()], + r0v[0].state.as_slice(), + ) + .is_err()); + } } use js_sys::Array; @@ -1529,6 +1910,8 @@ pub struct Share { share: Vec, pk: Vec, chaincode: Vec, + vrf_pk: Vec, + vrf_chaincode: Vec, } #[wasm_bindgen] @@ -1547,6 +1930,26 @@ impl Share { pub fn chaincode(&self) -> Vec { self.chaincode.clone() } + + #[wasm_bindgen(getter)] + pub fn vrf_pk(&self) -> Vec { + self.vrf_pk.clone() + } + + #[wasm_bindgen(getter)] + pub fn vrf_chaincode(&self) -> Vec { + self.vrf_chaincode.clone() + } +} + +fn share_from_mps(result: mps::Share) -> Share { + Share { + share: result.share, + pk: result.pk.to_vec(), + chaincode: result.chaincode.to_vec(), + vrf_pk: result.vrf_pk.map(|p| p.to_vec()).unwrap_or_default(), + vrf_chaincode: result.vrf_chaincode.map(|p| p.to_vec()).unwrap_or_default(), + } } #[wasm_bindgen] @@ -1660,13 +2063,19 @@ pub fn ed25519_dkg_round0_process( decryption_key: &[u8], encryption_keys: Array, seed: &[u8], + with_vrf: bool, ) -> Result { let decryption_key_32: [u8; 32] = decryption_key.try_into().map_err(|_| "Invalid input")?; let seed_32: [u8; 32] = seed.try_into().map_err(|_| "Invalid input")?; let [ek0, ek1] = js_array_to_2_bufs(&encryption_keys)?; - let result = - mps::ed25519_dkg_round0_process(party_id, &decryption_key_32, &[ek0, ek1], &seed_32) - .map_err(|e| e.to_string())?; + let result = mps::ed25519_dkg_round0_process( + party_id, + &decryption_key_32, + &[ek0, ek1], + &seed_32, + with_vrf, + ) + .map_err(|e| e.to_string())?; Ok(MsgState { msg: result.msg, @@ -1726,12 +2135,7 @@ pub fn ed25519_dkg_round1_process( pub fn ed25519_dkg_round2_process(round2_messages: Array, state: &[u8]) -> Result { let [m0, m1] = js_array_to_2_bufs(&round2_messages)?; let result = mps::ed25519_dkg_round2_process(&[m0, m1], state).map_err(|e| e.to_string())?; - - Ok(Share { - share: result.share, - pk: result.pk.to_vec(), - chaincode: result.chaincode.to_vec(), - }) + Ok(share_from_mps(result)) } #[wasm_bindgen] @@ -1779,6 +2183,52 @@ pub fn ed25519_dsg_round3_process(round3_message: &[u8], state: &[u8]) -> Result Ok(result.to_vec()) } +#[wasm_bindgen] +pub fn ed25519_encode_root_document(signing: &[u8], vrf: &[u8]) -> Result, String> { + mps::encode_root_document_from_bytes(signing, vrf).map_err(|e| e.to_string()) +} + +#[wasm_bindgen] +pub fn ed25519_hard_derive_round0_process( + root_share: &[u8], + path: &[u8], + seed: &[u8], +) -> Result { + let result = mps::ed25519_hard_derive_round0_process(root_share, path, seed) + .map_err(|e| e.to_string())?; + Ok(MsgState { + msg: result.msg, + state: result.state, + }) +} + +#[wasm_bindgen] +pub fn ed25519_hard_derive_round1_process( + round1_message: &[u8], + state: &[u8], +) -> Result { + let result = mps::ed25519_hard_derive_round1_process(round1_message, state) + .map_err(|e| e.to_string())?; + Ok(MsgState { + msg: result.msg, + state: result.state, + }) +} + +#[wasm_bindgen] +pub fn ed25519_hard_derive_round2_process( + round2_message: &[u8], + state: &[u8], + participating_party_ids: &[u8], +) -> Result { + let ids: [u8; 2] = participating_party_ids + .try_into() + .map_err(|_| "participating_party_ids must be exactly 2 ids")?; + let result = mps::ed25519_hard_derive_round2_process(round2_message, state, &ids) + .map_err(|e| e.to_string())?; + Ok(share_from_mps(result)) +} + #[wasm_bindgen] pub fn redpallas_dkg_round0_process( party_id: u8, diff --git a/packages/wasm-mps/test/mps.ts b/packages/wasm-mps/test/mps.ts index 97031b53927..1625a115ad3 100644 --- a/packages/wasm-mps/test/mps.ts +++ b/packages/wasm-mps/test/mps.ts @@ -2,7 +2,7 @@ import assert from "assert"; import crypto from "crypto"; import * as mps from "../js"; import sodium from "libsodium-wrappers-sumo"; -import { makeImportShares, runDsg, runImportDkg } from "./utils.js"; +import { makeImportShares, runDkgRound0, runDsg, runHardDerive, runRootDkg } from "./utils.js"; await sodium.ready; @@ -40,6 +40,7 @@ describe("mps", function () { keypairs[i].privateKey, otherIndices[i].map((i) => keypairs[i].publicKey), crypto.randomBytes(32), + false, ); assert(Buffer.from(result.msg).slice(0, messagePrefix.length).equals(messagePrefix)); assert(Buffer.from(result.state).slice(0, statePrefix.length).equals(statePrefix)); @@ -55,6 +56,7 @@ describe("mps", function () { keypairs[i].privateKey, otherIndices[i].map((i) => keypairs[i].publicKey), crypto.randomBytes(32), + false, ), ); }); @@ -186,6 +188,50 @@ describe("mps", function () { } }); + it("with_vrf=false is byte-identical across runs with the same seeds", function () { + const seeds: [Buffer, Buffer, Buffer] = [ + Buffer.alloc(32, 1), + Buffer.alloc(32, 2), + Buffer.alloc(32, 3), + ]; + const a = runDkgRound0(keypairs, seeds, false); + const b = runDkgRound0(keypairs, seeds, false); + for (let i = 0; i < 3; i++) { + assert(Buffer.from(a[i].msg).equals(Buffer.from(b[i].msg))); + assert(Buffer.from(a[i].state).equals(Buffer.from(b[i].state))); + } + const sharesA = runRootDkg(keypairs, false, seeds); + const sharesB = runRootDkg(keypairs, false, seeds); + for (let i = 0; i < 3; i++) { + assert(Buffer.from(sharesA[i].share).equals(Buffer.from(sharesB[i].share))); + assert.equal(sharesA[i].vrf_pk.length, 0); + } + }); + + it("with_vrf=true produces a combined root and agreeing VRF public keys", function () { + const shares = runRootDkg(keypairs, true); + for (let i = 0; i < 2; i++) { + assert(Buffer.from(shares[i].pk).equals(Buffer.from(shares[2].pk))); + assert(Buffer.from(shares[i].chaincode).equals(Buffer.from(shares[2].chaincode))); + assert(Buffer.from(shares[i].vrf_pk).equals(Buffer.from(shares[2].vrf_pk))); + assert(Buffer.from(shares[i].vrf_chaincode).equals(Buffer.from(shares[2].vrf_chaincode))); + } + assert(shares[0].vrf_pk.length === 32); + shouldThrow(() => mps.ed25519_dsg_round0_process(shares[0].share, "m", Buffer.from("x"))); + }); + + it("rejects mixed VRF and non-VRF round1 messages", function () { + const seeds: [Buffer, Buffer, Buffer] = [ + Buffer.alloc(32, 4), + Buffer.alloc(32, 5), + Buffer.alloc(32, 6), + ]; + const plain = runDkgRound0(keypairs, seeds, false); + const vrf = runDkgRound0(keypairs, seeds, true); + shouldThrow(() => mps.ed25519_dkg_round1_process([vrf[1].msg, vrf[2].msg], plain[0].state)); + shouldThrow(() => mps.ed25519_dkg_round1_process([plain[1].msg, vrf[2].msg], vrf[0].state)); + }); + describe("input handling", function () { describe("round0_process", function () { it("does not panic on bad party size", function () { @@ -195,6 +241,7 @@ describe("mps", function () { Buffer.alloc(32), [Buffer.alloc(32), Buffer.alloc(32)], crypto.randomBytes(32), + false, ), ); }); @@ -206,6 +253,7 @@ describe("mps", function () { "encryption key", [Buffer.alloc(32), Buffer.alloc(32)], crypto.randomBytes(32), + false, ), ); shouldThrow(() => @@ -214,6 +262,7 @@ describe("mps", function () { Buffer.alloc(0), [Buffer.alloc(32), Buffer.alloc(32)], crypto.randomBytes(32), + false, ), ); }); @@ -225,10 +274,11 @@ describe("mps", function () { Buffer.alloc(0), "decryption keys", crypto.randomBytes(32), + false, ), ); shouldThrow(() => - mps.ed25519_dkg_round0_process(0, Buffer.alloc(0), [], crypto.randomBytes(32)), + mps.ed25519_dkg_round0_process(0, Buffer.alloc(0), [], crypto.randomBytes(32), false), ); shouldThrow(() => mps.ed25519_dkg_round0_process( @@ -236,6 +286,7 @@ describe("mps", function () { Buffer.alloc(0), ["decryption key"], crypto.randomBytes(32), + false, ), ); shouldThrow(() => @@ -244,6 +295,7 @@ describe("mps", function () { Buffer.alloc(0), [Buffer.alloc(0)], crypto.randomBytes(32), + false, ), ); shouldThrow(() => @@ -252,6 +304,7 @@ describe("mps", function () { Buffer.alloc(0), [Buffer.alloc(32), Buffer.alloc(0)], crypto.randomBytes(32), + false, ), ); }); @@ -263,6 +316,7 @@ describe("mps", function () { Buffer.alloc(0), [Buffer.alloc(32), Buffer.alloc(32)], "seed", + false, ), ); shouldThrow(() => @@ -271,6 +325,7 @@ describe("mps", function () { Buffer.alloc(0), [Buffer.alloc(32), Buffer.alloc(32)], Buffer.alloc(0), + false, ), ); }); @@ -493,6 +548,7 @@ describe("mps", function () { keypairs[i].privateKey, otherIndices[i].map((i) => keypairs[i].publicKey), crypto.randomBytes(32), + false, ), ); const results2 = [0, 1, 2].map((i) => @@ -1163,4 +1219,115 @@ describe("mps", function () { }); }); }); + + describe("hard derivation (interleaved VRF DKG + hard derive)", function () { + function fromHex(s: string): Uint8Array { + return new Uint8Array(Buffer.from(s, "hex")); + } + + function signChild(a: mps.Share, b: mps.Share, message: Buffer): Uint8Array { + const dsg0 = [a, b].map((s) => mps.ed25519_dsg_round0_process(s.share, "m", message)); + const dsg1 = [0, 1].map((i) => + mps.ed25519_dsg_round1_process(dsg0[i ^ 1].msg, dsg0[i].state), + ); + const dsg2 = [0, 1].map((i) => + mps.ed25519_dsg_round2_process(dsg1[i ^ 1].msg, dsg1[i].state), + ); + const [sig0, sig1] = [0, 1].map((i) => + mps.ed25519_dsg_round3_process(dsg2[i ^ 1].msg, dsg2[i].state), + ); + assert(Buffer.from(sig0).equals(Buffer.from(sig1))); + return sig0; + } + + describe("full chain: interleaved DKG -> hard derive -> sign", function () { + it("derives a consistent child from every 2-of-3 quorum", function () { + const rootShares = runRootDkg(keypairs, true); + const path = "m/999999'/0'"; + const extraPath = "m/44'/0'/0'"; + const hdSeeds: [Buffer, Buffer] = [Buffer.alloc(32, 10), Buffer.alloc(32, 11)]; + const quorums: Array<[number, number]> = [ + [0, 1], + [0, 2], + [1, 2], + ]; + + const derivedByQuorum = quorums.map((q) => runHardDerive(rootShares, path, hdSeeds, q)); + for (const [d0, d1] of derivedByQuorum) { + assert(Buffer.from(d0.pk).equals(Buffer.from(d1.pk))); + assert(Buffer.from(d0.chaincode).equals(Buffer.from(d1.chaincode))); + assert(!Buffer.from(d0.pk).equals(Buffer.from(rootShares[0].pk))); + } + assert(Buffer.from(derivedByQuorum[0][0].pk).equals(Buffer.from(derivedByQuorum[1][0].pk))); + assert(Buffer.from(derivedByQuorum[1][0].pk).equals(Buffer.from(derivedByQuorum[2][0].pk))); + + const extra = runHardDerive(rootShares, extraPath, hdSeeds, [0, 2]); + assert(!Buffer.from(extra[0].pk).equals(Buffer.from(derivedByQuorum[0][0].pk))); + + const message = Buffer.from("hard-derive harness test message"); + const [derived0, derived2] = derivedByQuorum[1]; + const sig = signChild(derived0, derived2, message); + assert( + sodium.crypto_sign_verify_detached(Buffer.from(sig), message, Buffer.from(derived0.pk)), + ); + }); + }); + + describe("golden vector (cross-implementation agreement with sl-mps)", function () { + it("matches the derived pk/chaincode produced by the Rust (sl-mps) implementation", function () { + // Fixture bytes captured from one run of sl-mps's + // `vrf_dkg_hard_derive_and_sign_ed25519` test (root + VRF Keyshares + // for parties 0/1, bincode-encoded) and duplicated verbatim in + // hsm-firmware's `src/sl-mps/src/lib.rs` (`hard_derive_matches_golden_vector`). + const root0 = fromHex( + "020300510ba2d9807d478162c122ee6bd93fbf01c162554461bef21b57e94d7bc02903fed89f7b5f76a1f9956d12f1dca812b13cef803e7d75e3f70c1a12e65b26e7b2602fef6c18da4b231dab4f3aa1bb8548285f2de1f6ef1919840988504c60a21979795dc2091feed51e7a4fe3a23f7efb45c7565310180f5f5a05b6b72f960c729442307047067dfd9252c2c6696463253189b8052e393d67f1a49d92682da4f63e11b9aedf8726986b38496701900889d1f6390a6e2ce3d9f5ff109f5ae08f89b500c81e70b32e37854ef128d34706f53768bd106c2166b50b5f86ae1dc366a354f1ba9b44929c3b69660732137fc02757d6bc86e4356865a22324995ea65590fe8b", + ); + const root1 = fromHex( + "02030179256ee54665afb0cac79b0cc71c55997be50b6ee51e08a20b1f7d3e26d05a0afed89f7b5f76a1f9956d12f1dca812b13cef803e7d75e3f70c1a12e65b26e7b2602fef6c18da4b231dab4f3aa1bb8548285f2de1f6ef1919840988504c60a21979795dc2091feed51e7a4fe3a23f7efb45c7565310180f5f5a05b6b72f960c729442307047067dfd9252c2c6696463253189b8052e393d67f1a49d92682da4f63e11b9aedf8726986b38496701900889d1f6390a6e2ce3d9f5ff109f5ae08f89b500c81e70b32e37854ef128d34706f53768bd106c2166b50b5f86ae1dc366a354f1ba9b44929c3b69660732137fc02757d6bc86e4356865a22324995ea65590fe8b", + ); + const vrf0 = fromHex( + "0203007e9d1f3c4cab22497d43d2b42db7e6dbd8e5e5832fbc4591c1c660194ca00d07703059881be18310acf8d3c4245745bf920cc1d2a4366000e6574b04ecd7f119601cceadb743fc50a88c3cbd60449f2c76cf4a72f15835d87a9c447ad58523f95824b902529eee1abb183e328a2d79473b3216649c6cd28c6d5e67f0cb74db8828fcbed41bf2cd2f83ef58bf05218e70da4dbdfeb3f45bb801c981f95e1688083a5b7db6feeb887fb76524245d34fac53acb5d0be01159faaa1e56de48fd68af3500c43dd8d30860e7468984f03fe5722ec702e34ebc4bc449195f999cb26949eb4ffd52fd5af3fa730c78706c7943cf409e526b46adf46914e0188948acf934eea0", + ); + const vrf1 = fromHex( + "020301e50d4d81352589c35ca45cc2605f20ba51e089aa0fc2210e0ae29dc98124e406703059881be18310acf8d3c4245745bf920cc1d2a4366000e6574b04ecd7f119601cceadb743fc50a88c3cbd60449f2c76cf4a72f15835d87a9c447ad58523f95824b902529eee1abb183e328a2d79473b3216649c6cd28c6d5e67f0cb74db8828fcbed41bf2cd2f83ef58bf05218e70da4dbdfeb3f45bb801c981f95e1688083a5b7db6feeb887fb76524245d34fac53acb5d0be01159faaa1e56de48fd68af3500c43dd8d30860e7468984f03fe5722ec702e34ebc4bc449195f999cb26949eb4ffd52fd5af3fa730c78706c7943cf409e526b46adf46914e0188948acf934eea0", + ); + + const expectedPk = fromHex( + "c11014176342e28c839709e764f57df045332e518e9fef163466be19b0df8892", + ); + const expectedChaincode = fromHex( + "709028c8a5d46c58a8c42a527201c5d6c4964002a21003620c8efffebb9ac7bc", + ); + + const path = Buffer.from("m/999999'/0'"); + const seed0 = Buffer.alloc(32, 20); + const seed1 = Buffer.alloc(32, 21); + const participatingIds = new Uint8Array([0, 1]); + const doc0 = mps.ed25519_encode_root_document(root0, vrf0); + const doc1 = mps.ed25519_encode_root_document(root1, vrf1); + + const r0 = [ + mps.ed25519_hard_derive_round0_process(doc0, path, seed0), + mps.ed25519_hard_derive_round0_process(doc1, path, seed1), + ]; + const r1 = [0, 1].map((i) => + mps.ed25519_hard_derive_round1_process(r0[i ^ 1].msg, r0[i].state), + ); + const derived0 = mps.ed25519_hard_derive_round2_process( + r1[1].msg, + r1[0].state, + participatingIds, + ); + + assert( + Buffer.from(derived0.pk).equals(Buffer.from(expectedPk)), + "derived pk must match the golden vector produced by sl-mps", + ); + assert( + Buffer.from(derived0.chaincode).equals(Buffer.from(expectedChaincode)), + "derived chaincode must match the golden vector produced by sl-mps", + ); + }); + }); + }); }); diff --git a/packages/wasm-mps/test/utils.ts b/packages/wasm-mps/test/utils.ts index 3377ba3425e..aae6e3856a4 100644 --- a/packages/wasm-mps/test/utils.ts +++ b/packages/wasm-mps/test/utils.ts @@ -84,3 +84,80 @@ export function runDsg( const sigs = [0, 1].map((i) => mps.ed25519_dsg_round3_process(dsg2[i ^ 1].msg, dsg2[i].state)); return [sigs[0], sigs[1]]; } + +const OTHER_IDX = [ + [1, 2], + [0, 2], + [0, 1], +]; + +/** Run one DKG round0 for all 3 parties. */ +export function runDkgRound0( + keypairs: Array<{ privateKey: Uint8Array; publicKey: Uint8Array }>, + seeds: [Buffer, Buffer, Buffer], + withVrf: boolean, +): mps.MsgState[] { + return [0, 1, 2].map((i) => + mps.ed25519_dkg_round0_process( + i, + keypairs[i].privateKey, + OTHER_IDX[i].map((j) => keypairs[j].publicKey), + seeds[i], + withVrf, + ), + ); +} + +/** Run the real (non-import) ed25519 DKG (r0→r2) for 3 parties. */ +export function runRootDkg( + keypairs: Array<{ privateKey: Uint8Array; publicKey: Uint8Array }>, + withVrf = false, + seeds?: [Buffer, Buffer, Buffer], +): mps.Share[] { + const dkgSeeds: [Buffer, Buffer, Buffer] = seeds ?? [ + crypto.randomBytes(32), + crypto.randomBytes(32), + crypto.randomBytes(32), + ]; + + const r0 = runDkgRound0(keypairs, dkgSeeds, withVrf); + + const r1 = [0, 1, 2].map((i) => + mps.ed25519_dkg_round1_process( + OTHER_IDX[i].map((j) => r0[j].msg), + r0[i].state, + ), + ); + + return [0, 1, 2].map((i) => + mps.ed25519_dkg_round2_process( + OTHER_IDX[i].map((j) => r1[j].msg), + r1[i].state, + ), + ); +} + +/** + * Run hard derivation (r0→r2) for 2 of the 3 combined-root holders — a + * genuine 2-of-3 threshold ceremony. Round0 bootstraps alone; round1/round2 + * take a single peer message each. + */ +export function runHardDerive( + rootShares: mps.Share[], + path: string, + seeds: [Buffer, Buffer], + participants: [number, number] = [0, 2], +): [mps.Share, mps.Share] { + const participatingIds = new Uint8Array(participants); + + const hd0 = participants.map((p, i) => + mps.ed25519_hard_derive_round0_process(rootShares[p].share, Buffer.from(path), seeds[i]), + ); + const hd1 = [0, 1].map((i) => + mps.ed25519_hard_derive_round1_process(hd0[i ^ 1].msg, hd0[i].state), + ); + const derived = [0, 1].map((i) => + mps.ed25519_hard_derive_round2_process(hd1[i ^ 1].msg, hd1[i].state, participatingIds), + ); + return [derived[0], derived[1]]; +}