From c9139569cce3a609329e2a10dc38228dcbcdad4a Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Tue, 4 Aug 2026 18:52:19 +0530 Subject: [PATCH] feat(wasm-mps): add ed25519_dkg_round0_import for MPCv2 retrofit --- packages/wasm-mps/src/lib.rs | 242 ++++++++++++++++++++++++++++++++- packages/wasm-mps/test/mps.ts | 243 ++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 1 deletion(-) diff --git a/packages/wasm-mps/src/lib.rs b/packages/wasm-mps/src/lib.rs index e5f495800b6..8d95005bbf6 100644 --- a/packages/wasm-mps/src/lib.rs +++ b/packages/wasm-mps/src/lib.rs @@ -11,13 +11,15 @@ mod mps { }, curve25519_dalek::EdwardsPoint, keygen::{ - KeygenMsg1, KeygenMsg2, KeygenParty, Keyshare, R0 as DkgR0, R1 as DkgR1, R2 as DkgR2, + KeyRefreshData, KeygenMsg1, KeygenMsg2, KeygenParty, Keyshare, R0 as DkgR0, + R1 as DkgR1, R2 as DkgR2, }, sign::{ messages::{SignMsg1, SignMsg2, SignMsg3}, PartialSign, SignError, SignReady, SignerParty, R0 as DsgR0, R1 as DsgR1, R2 as DsgR2, }, }; + use rand::Rng; use serde::{Deserialize, Serialize}; use std::{ io::{Cursor, Read}, @@ -551,6 +553,124 @@ mod mps { }) } + fn internal_dkg_round0_import( + party_id: u8, + decryption_key: &[u8; 32], + encryption_keys: &[Vec; 2], + s_i_0: G::Scalar, + expected_pk: G, + chain_code: [u8; 32], + ) -> Result + where + G: GroupElem, + G::Scalar: ScalarReduce<[u8; 32]> + Serializable, + { + if party_id >= 3 { + return Err(MpsError::InvalidInput); + } + + // Parse decryption key + let secret_key = crypto_box::SecretKey::from(*decryption_key); + + // Parse all party encryption keys + let i0_pk = crypto_box::PublicKey::from( + <[u8; 32]>::try_from(encryption_keys[0].clone()).map_err(|_| MpsError::InvalidInput)?, + ); + let i1_pk = crypto_box::PublicKey::from( + <[u8; 32]>::try_from(encryption_keys[1].clone()).map_err(|_| MpsError::InvalidInput)?, + ); + let mut public_keys = Vec::new(); + if party_id == 0 { + public_keys.push((1u8, i0_pk)); + public_keys.push((2u8, i1_pk)); + } else if party_id == 1 { + public_keys.push((0u8, i0_pk)); + public_keys.push((2u8, i1_pk)); + } else { + public_keys.push((0u8, i0_pk)); + public_keys.push((1u8, i1_pk)); + } + public_keys.push((party_id, secret_key.public_key())); + + // Build refresh data from the MPCv1 additive scalar and expected public key + let refresh_data = KeyRefreshData::make_refresh_data_migrate( + party_id, + 2, + 3, + s_i_0, + expected_pk, + chain_code, + ); + + // Create KeygenParty with refresh_data so the protocol re-shares from the existing key + let p0 = KeygenParty::::new( + 2, // threshold + 3, // total parties + party_id, + Arc::new(secret_key), + public_keys, + Some(refresh_data), // refresh_data + None, // key_id + rand::thread_rng().gen(), + None, // extra_data + ) + .map_err(|_| MpsError::ProtocolError)?; + + // Generate message + let (p1, msg1) = p0.process(()).map_err(|_| MpsError::ProtocolError)?; + + // Create the state for storage between rounds + let state = DkgStateR1 { + party_id, + msg: msg1, + party: p1, + }; + + 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)?, + }) + } + + /// MPCv1 → MPCv2 retrofit: round 0 of DKG import for Ed25519. + /// party_id: Party identifier / index. + /// decryption_key: Private Curve25519 key. + /// encryption_keys: Public Curve25519 keys of other parties. + /// s_i_0: 32 bytes LE — additive scalar (pShare.u, clamped). + /// expected_pk: 32 bytes — aggregate Ed25519 public key (pShare.y). + /// chain_code: 32 bytes — root chain code (pShare.chaincode). + pub fn ed25519_dkg_round0_import( + party_id: u8, + decryption_key: &[u8; 32], + encryption_keys: &[Vec; 2], + s_i_0: &[u8; 32], + expected_pk: &[u8; 32], + chain_code: &[u8; 32], + ) -> Result { + use multi_party_schnorr::curve25519_dalek::{edwards::CompressedEdwardsY, Scalar}; + + let scalar = Scalar::from_bytes_mod_order(*s_i_0); + let pub_key = CompressedEdwardsY(*expected_pk) + .decompress() + .ok_or(MpsError::InvalidInput)?; + + let result = internal_dkg_round0_import::( + party_id, + decryption_key, + encryption_keys, + scalar, + pub_key, + *chain_code, + )?; + + Ok(MsgState { + msg: add_prefix("mps-ed25519-dkg-round1-message$", &result.msg), + state: add_prefix("mps-ed25519-dkg-round1-state$", &result.state), + }) + } + /// Process round 1 of DKG protocol. /// round1_messages: Public messages from other parties. /// state: Private state result from from round 0. @@ -1027,6 +1147,92 @@ mod tests { ); } + /// Test full DKG import protocol (MPCv1 → MPCv2 retrofit). + #[test] + fn test_ed25519_dkg_import() { + use multi_party_schnorr::curve25519_dalek::{ + constants::ED25519_BASEPOINT_POINT, edwards::CompressedEdwardsY, Scalar, + }; + + let mut rng = rand::thread_rng(); + + // Synthesize 3 additive scalar shares and derive the expected public key. + let scalar_bytes: Vec<[u8; 32]> = (0..3).map(|_| rng.gen()).collect(); + let scalars: Vec = scalar_bytes + .iter() + .map(|b| Scalar::from_bytes_mod_order(*b)) + .collect(); + let sum = scalars[0] + scalars[1] + scalars[2]; + let expected_pk: [u8; 32] = (ED25519_BASEPOINT_POINT * sum).compress().to_bytes(); + let chain_code: [u8; 32] = rng.gen(); + + let mut prv_keys = Vec::new(); + let mut pub_keys: Vec = Vec::new(); + for _ in 0..3 { + let sk = crypto_box::SecretKey::generate(&mut rng); + pub_keys.push(sk.public_key()); + prv_keys.push(sk); + } + + let other_indices = [[1usize, 2], [0, 2], [0, 1]]; + + let r0: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round0_import( + i as u8, + &prv_keys[i].to_bytes(), + &[ + pub_keys[other_indices[i][0]].to_bytes().to_vec(), + pub_keys[other_indices[i][1]].to_bytes().to_vec(), + ], + &scalars[i].to_bytes(), + &expected_pk, + &chain_code, + ) + .unwrap() + }) + .collect(); + + let r1: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round1_process( + &[ + r0[other_indices[i][0]].msg.clone(), + r0[other_indices[i][1]].msg.clone(), + ], + &r0[i].state, + ) + .unwrap() + }) + .collect(); + + let shares: Vec<_> = (0..3) + .map(|i| { + mps::ed25519_dkg_round2_process( + &[ + r1[other_indices[i][0]].msg.clone(), + r1[other_indices[i][1]].msg.clone(), + ], + &r1[i].state, + ) + .unwrap() + }) + .collect(); + + // All 3 parties agree on pk and chaincode, both match the inputs. + for (i, share) in shares.iter().enumerate() { + assert_eq!(share.pk, expected_pk, "party {i} pk mismatch"); + assert_eq!(share.chaincode, chain_code, "party {i} chaincode mismatch"); + } + assert_eq!(shares[0].pk, shares[1].pk); + assert_eq!(shares[0].pk, shares[2].pk); + + // Verify the public key is a valid Ed25519 point and matches expected. + let _ = CompressedEdwardsY(shares[0].pk) + .decompress() + .expect("pk must be a valid Ed25519 point"); + } + /// Test full DSG protocol. #[test] fn test_ed25519_dsg() { @@ -1468,6 +1674,40 @@ pub fn ed25519_dkg_round0_process( }) } +#[wasm_bindgen] +pub fn ed25519_dkg_round0_import( + party_id: u8, + decryption_key: &[u8], + encryption_keys: Array, + s_i_0: &[u8], + expected_pk: &[u8], + chain_code: &[u8], +) -> Result { + let decryption_key_32: [u8; 32] = decryption_key.try_into().map_err(|_| "Invalid input")?; + let s_i_0_32: [u8; 32] = s_i_0.try_into().map_err(|_| "s_i_0 must be 32 bytes")?; + let expected_pk_32: [u8; 32] = expected_pk + .try_into() + .map_err(|_| "expected_pk must be 32 bytes")?; + let chain_code_32: [u8; 32] = chain_code + .try_into() + .map_err(|_| "chain_code must be 32 bytes")?; + let [ek0, ek1] = js_array_to_2_bufs(&encryption_keys)?; + let result = mps::ed25519_dkg_round0_import( + party_id, + &decryption_key_32, + &[ek0, ek1], + &s_i_0_32, + &expected_pk_32, + &chain_code_32, + ) + .map_err(|e| e.to_string())?; + + Ok(MsgState { + msg: result.msg, + state: result.state, + }) +} + #[wasm_bindgen] pub fn ed25519_dkg_round1_process( round1_messages: Array, diff --git a/packages/wasm-mps/test/mps.ts b/packages/wasm-mps/test/mps.ts index f3886999d92..e3960ff4614 100644 --- a/packages/wasm-mps/test/mps.ts +++ b/packages/wasm-mps/test/mps.ts @@ -5,6 +5,85 @@ import sodium from "libsodium-wrappers-sumo"; await sodium.ready; +// --------------------------------------------------------------------------- +// Helpers for ed25519_dkg_round0_import tests +// --------------------------------------------------------------------------- + +/** Clamp a 32-byte LE scalar to Ed25519 format (matching pShare.u). */ +function clampScalar(bytes: Buffer): Buffer { + const s = Buffer.from(bytes); + s[0] &= 248; // clear bits 0-2 + s[31] &= 127; // clear bit 255 + s[31] |= 64; // set bit 254 + return s; +} + +/** + * Generate `n` clamped scalars and compute the aggregate Ed25519 public key + * expected_pk = G × (s_0 + s_1 + ... + s_{n-1}). + * + * Uses libsodium: + * - crypto_scalarmult_ed25519_base_noclamp to compute each G×s_i + * - crypto_core_ed25519_add to sum the resulting points + */ +function makeImportShares(n = 3): { scalars: Buffer[]; expectedPk: Buffer; chainCode: Buffer } { + const scalars = Array.from({ length: n }, () => clampScalar(crypto.randomBytes(32))); + + // G×s_i for each party, then add all points together + const points = scalars.map((s) => sodium.crypto_scalarmult_ed25519_base_noclamp(s)); + const expectedPk = Buffer.from(points.reduce((acc, p) => sodium.crypto_core_ed25519_add(acc, p))); + const chainCode = crypto.randomBytes(32); + return { scalars, expectedPk, chainCode }; +} + +/** Run the full import DKG (round0_import → round1 → round2) for 3 parties. */ +function runImportDkg( + keypairs: Array<{ privateKey: Uint8Array; publicKey: Uint8Array }>, + scalars: Buffer[], + expectedPk: Buffer, + chainCode: Buffer, +): mps.Share[] { + const otherIdx = [ + [1, 2], + [0, 2], + [0, 1], + ]; + + const r0 = [0, 1, 2].map((i) => + mps.ed25519_dkg_round0_import( + i, + keypairs[i].privateKey, + otherIdx[i].map((j) => keypairs[j].publicKey), + scalars[i], + expectedPk, + chainCode, + ), + ); + + const r1 = [0, 1, 2].map((i) => + mps.ed25519_dkg_round1_process( + otherIdx[i].map((j) => r0[j].msg), + r0[i].state, + ), + ); + + return [0, 1, 2].map((i) => + mps.ed25519_dkg_round2_process( + otherIdx[i].map((j) => r1[j].msg), + r1[i].state, + ), + ); +} + +/** Run the full 4-round DSG (r0→r3) for parties 0 and 2. Returns both signatures. */ +function runDsg(shares: mps.Share[], path: string, message: Buffer): [Uint8Array, Uint8Array] { + const dsg0 = [0, 2].map((i) => mps.ed25519_dsg_round0_process(shares[i].share, path, 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 sigs = [0, 1].map((i) => mps.ed25519_dsg_round3_process(dsg2[i ^ 1].msg, dsg2[i].state)); + return [sigs[0], sigs[1]]; +} + describe("mps", function () { const otherIndices = [ [1, 2], @@ -317,6 +396,170 @@ describe("mps", function () { }); }); + describe("dkg_import", function () { + let importScalars: Buffer[]; + let importExpectedPk: Buffer; + let importChainCode: Buffer; + + before("generates synthetic MPCv1 shares", function () { + ({ + scalars: importScalars, + expectedPk: importExpectedPk, + chainCode: importChainCode, + } = makeImportShares()); + }); + + it("performs round 0 import", function () { + const messagePrefix = Buffer.from("mps-ed25519-dkg-round1-message$"); + const statePrefix = Buffer.from("mps-ed25519-dkg-round1-state$"); + for (let i = 0; i < keypairs.length; i++) { + const result = mps.ed25519_dkg_round0_import( + i, + keypairs[i].privateKey, + otherIndices[i].map((j) => keypairs[j].publicKey), + importScalars[i], + importExpectedPk, + importChainCode, + ); + assert(Buffer.from(result.msg).slice(0, messagePrefix.length).equals(messagePrefix)); + assert(Buffer.from(result.state).slice(0, statePrefix.length).equals(statePrefix)); + } + }); + + let r0Results: Array; + + before("performs round 0 import", function () { + r0Results = [0, 1, 2].map((i) => + mps.ed25519_dkg_round0_import( + i, + keypairs[i].privateKey, + otherIndices[i].map((j) => keypairs[j].publicKey), + importScalars[i], + importExpectedPk, + importChainCode, + ), + ); + }); + + it("performs round 1", function () { + const messagePrefix = Buffer.from("mps-ed25519-dkg-round2-message$"); + const statePrefix = Buffer.from("mps-ed25519-dkg-round2-state$"); + for (let i = 0; i < r0Results.length; i++) { + const result = mps.ed25519_dkg_round1_process( + otherIndices[i].map((j) => r0Results[j].msg), + r0Results[i].state, + ); + assert(Buffer.from(result.msg).slice(0, messagePrefix.length).equals(messagePrefix)); + assert(Buffer.from(result.state).slice(0, statePrefix.length).equals(statePrefix)); + } + }); + + let r1Results: Array; + + before("performs round 1", function () { + r1Results = [0, 1, 2].map((i) => + mps.ed25519_dkg_round1_process( + otherIndices[i].map((j) => r0Results[j].msg), + r0Results[i].state, + ), + ); + }); + + it("performs round 2", function () { + const shares = [0, 1, 2].map((i) => + mps.ed25519_dkg_round2_process( + otherIndices[i].map((j) => r1Results[j].msg), + r1Results[i].state, + ), + ); + for (let i = 0; i < 2; i++) { + assert.ok(shares[i].pk.every((value, index) => value === shares[2].pk[index])); + assert.ok( + shares[i].chaincode.every((value, index) => value === shares[2].chaincode[index]), + ); + } + for (const [i, s] of shares.entries()) { + assert.ok( + Buffer.from(s.pk).equals(importExpectedPk), + `party ${i}: Share.pk !== expected_pk`, + ); + assert.ok( + Buffer.from(s.chaincode).equals(importChainCode), + `party ${i}: Share.chaincode !== chain_code`, + ); + } + }); + + let importShares: mps.Share[]; + + before("performs round 2", function () { + importShares = [0, 1, 2].map((i) => + mps.ed25519_dkg_round2_process( + otherIndices[i].map((j) => r1Results[j].msg), + r1Results[i].state, + ), + ); + }); + + it("signing round-trip at root path verifies against expected_pk", function () { + const message = Buffer.from("test message for import DKG signing"); + const [sig0, sig2] = runDsg(importShares, "m", message); + assert.ok( + sodium.crypto_sign_verify_detached(sig0, message, importExpectedPk), + "sig0 failed to verify", + ); + assert.ok( + sodium.crypto_sign_verify_detached(sig2, message, importExpectedPk), + "sig2 failed to verify", + ); + assert.deepStrictEqual(sig0, sig2, "both parties must produce identical signatures"); + }); + + // Full derived pubkey verification requires BitGoJS Eddsa.deriveUnhardened, covered separately. + it("signing round-trip at derived path m/0 produces a valid signature", function () { + const message = Buffer.from("test message for derived path signing"); + const [sig0, sig2] = runDsg(importShares, "m/0", message); + assert.strictEqual(sig0.length, 64, "signature must be 64 bytes"); + assert.deepStrictEqual(sig0, sig2, "both parties must produce identical signatures"); + }); + + // The wrong pk is a valid Ed25519 point but does not equal G × Σs_i_0, so the + // mismatch surfaces during the protocol (at round2_process per the MPS library). + it("rejects mismatched expected_pk — error surfaces during the protocol", function () { + const wrongPk = Buffer.from( + sodium.crypto_scalarmult_ed25519_base_noclamp(crypto.randomBytes(32)), + ); + let threw = false; + try { + const r0 = [0, 1, 2].map((i) => + mps.ed25519_dkg_round0_import( + i, + keypairs[i].privateKey, + otherIndices[i].map((j) => keypairs[j].publicKey), + importScalars[i], + wrongPk, + importChainCode, + ), + ); + const r1 = [0, 1, 2].map((i) => + mps.ed25519_dkg_round1_process( + otherIndices[i].map((j) => r0[j].msg), + r0[i].state, + ), + ); + [0, 1, 2].map((i) => + mps.ed25519_dkg_round2_process( + otherIndices[i].map((j) => r1[j].msg), + r1[i].state, + ), + ); + } catch { + threw = true; + } + assert.ok(threw, "expected protocol to fail when expected_pk does not match Σs_i_0"); + }); + }); + describe("dsg", function () { const otherIndex = [1, 0]; let shares: Array;