diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index f769efb99fd..b35f6c07d2d 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.90.0", + "@bitgo/public-types": "6.91.0", "@bitgo/sdk-lib-mpc": "^11.5.0", "@bitgo/sdk-lib-safes": "^1.6.0", "@bitgo/secp256k1": "^1.11.1", diff --git a/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts index 248a5e5e604..eb1eef20438 100644 --- a/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts +++ b/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import * as openpgp from 'openpgp'; import { RedPallasMPSComms, RedPallasMPSTypes } from '@bitgo/sdk-lib-mpc'; import { @@ -29,41 +30,74 @@ function partyIdToSignatureShareType(partyId: MPCv2PartiesEnum): SignatureShareT } /** - * RedPallas MPS DSG and rerand signature-share helpers. + * RedPallas MPS DSG and rerand signature-share helpers (multi-spend, WCI-1949). * - * Consumed by the custodial/cold (SMC/OVC) signing flow: OVC builds its rerand and DSG - * shares with these and submits them via the /sign txRequest route (see - * `sendSignatureShareV2`, body types `redPallasRerand` / `redPallasMpcV2`). No caller - * inside this SDK yet. Mirrors `../eddsa/eddsaMPCv2.ts` (same round shape, same - * PGP-signed-message envelope), but kept as an independent copy - built on - * `RedPallasMPSComms` - so RedPallas MPS never depends on the EdDSA MPS module, and - * vice versa. + * A shielded ZEC transaction can spend several Orchard notes; every per-spend + * value on the /sign wire is an ARRAY of length N ordered by spend index, where + * N is the number of Orchard spends + * (`txRequest.transactions[i].unsignedTx.parsedTx.inputs.length`), derived on + * the client and never a wire field. Each spend runs its own rerand session and + * its own DSG session (N instances of `RedPallasRerand` / `RedPallasDSG` — they + * are single-session by design; the per-spend fan-out lives in the caller, see + * OVC's `MpcSignerRedPallas`), but each round still produces exactly ONE + * `SignatureShareRecord` per transaction: the builders below take N + * PGP-signed messages in and emit one record whose `msgN` is the spend-ordered + * array; the verifiers take a parsed peer `roundNOutput` (msgN array) and + * return N verified messages out, in the same order. * - * Since `@bitgo/wasm-mps` 1.17.0, key re-randomization is a separate 2-round rerand - * protocol that must run before every RedPallas DSG, between the same 2 parties that - * will then sign: `getRerandShareRoundOne/Two` build those share records and - * `verifyPeerRerandMessageRoundOne/Two` unwrap the peer's rerand messages (see - * `RedPallasRerand` in `@bitgo/sdk-lib-mpc`). The rerand result exposes - * `{keyShare, pk, alpha}`; `pk` is the `rk` the DSG signature verifies under, and the - * DSG keyshare is the rerandomized `keyShare` - never the bare DKG keyshare. + * Builders: + * - assert a non-empty input array (N >= 1; an Orchard spend transaction has at + * least one input) and preserve spend order — Wallet Platform index-pairs + * `msgN[i]` with spend i's HSM state, so a misordered array silently mispairs + * spends. + * - emit exactly ONE share record per round — WP reads only the LAST share in + * `signatureShares`; multiple partial records would be silently dropped. + * + * Verifiers: + * - return one verified message per array entry, same order, all signed by the + * BitGo RedPallas GPG key; a single failed verification fails the whole round + * (all-or-nothing, mirroring WP's per-round share handling). + * + * Consumed by the custodial/cold (SMC/OVC) signing flow: OVC builds its rerand + * and DSG shares with these and submits them via the /sign txRequest route (see + * `sendSignatureShareV2`, body types `redPallasRerand` / `redPallasMpcV2`). No + * caller inside this SDK yet. Mirrors `../eddsa/eddsaMPCv2.ts` (same round + * shape, same PGP-signed-message envelope), but kept as an independent copy - + * built on `RedPallasMPSComms` - so RedPallas MPS never depends on the EdDSA MPS + * module, and vice versa. The EdDSA helpers themselves remain scalar + * (single-signable): EdDSA is not part of the multi-spend cutover. + * + * Since `@bitgo/wasm-mps` 1.17.0, key re-randomization is a separate 2-round + * rerand protocol that must run before every RedPallas DSG, between the same 2 + * parties that will then sign: `getRerandShareRoundOne/Two` build those share + * records and `verifyPeerRerandMessageRoundOne/Two` unwrap the peer's rerand + * messages (see `RedPallasRerand` in `@bitgo/sdk-lib-mpc`). Rerand R1 seeds + * every spend's session from the SAME DKG keyshare; after R2 each spend has its + * own rerand result `{keyShare, pk, alpha}`. The per-spend `keyShare` - never + * the bare DKG keyshare - initializes that spend's DSG session, and the DSG + * signature verifies under `rk == pk` of the same spend. */ /** * Builds the round-1 signature share record. * - * PGP-signs the WASM round-0 broadcast message with the signer's ephemeral key and - * wraps it into a SignatureShareRecord ready for `sendSignatureShareV2`. + * PGP-signs each spend's WASM round-0 broadcast message with the signer's + * ephemeral key and wraps all N of them, in spend order, into ONE + * SignatureShareRecord (`msg1` array) ready for `sendSignatureShareV2`. */ export async function getSignatureShareRoundOne( - userMsg1: RedPallasMPSTypes.DeserializedMessage, + userMsg1s: RedPallasMPSTypes.DeserializedMessage[], userGpgPrivKey: openpgp.PrivateKey, partyId: SignerPartyId = MPCv2PartiesEnum.USER, otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO ): Promise { - const signedMsg1 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey); + assert(userMsg1s.length >= 1, 'Expected at least 1 round-1 message (one per shielded spend)'); + const signedMsg1s = await Promise.all( + userMsg1s.map((userMsg1) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey)) + ); const share: RedpallasMPCv2SignatureShareRound1Input = { type: 'round1Input', - data: { msg1: signedMsg1 }, + data: { msg1: signedMsg1s }, }; return { from: partyIdToSignatureShareType(partyId), @@ -73,34 +107,43 @@ export async function getSignatureShareRoundOne( } /** - * Verifies the peer's round-1 PGP signature and returns the raw deserialized - * message ready for `RedPallasDSG.handleIncomingMessages`. + * Verifies the peer's round-1 PGP signatures and returns the raw deserialized + * messages, one per shielded spend in the peer's array order, ready for + * `RedPallasDSG.handleIncomingMessages` (feed `msg1[i]` into spend i's session). + * All-or-nothing: a single failed verification rejects the whole round. */ export async function verifyPeerMessageRoundOne( parsedRound1Output: RedpallasMPCv2SignatureShareRound1Output, peerGpgKey: openpgp.Key, peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO -): Promise { - const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, peerGpgKey); - return { - from: peerPartyId, - payload: new Uint8Array(rawBytes), - }; +): Promise { + const verifiedMsgs: RedPallasMPSTypes.DeserializedMessage[] = []; + for (const signedMsg1 of parsedRound1Output.data.msg1) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg1, peerGpgKey); + verifiedMsgs.push({ from: peerPartyId, payload: new Uint8Array(rawBytes) }); + } + return verifiedMsgs; } /** * Builds the round-2 signature share record. + * + * PGP-signs each spend's round-2 message and wraps all N of them, in spend + * order, into ONE SignatureShareRecord (`msg2` array). */ export async function getSignatureShareRoundTwo( - userMsg2: RedPallasMPSTypes.DeserializedMessage, + userMsg2s: RedPallasMPSTypes.DeserializedMessage[], userGpgPrivKey: openpgp.PrivateKey, partyId: SignerPartyId = MPCv2PartiesEnum.USER, otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO ): Promise { - const signedMsg2 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey); + assert(userMsg2s.length >= 1, 'Expected at least 1 round-2 message (one per shielded spend)'); + const signedMsg2s = await Promise.all( + userMsg2s.map((userMsg2) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey)) + ); const share: RedpallasMPCv2SignatureShareRound2Input = { type: 'round2Input', - data: { msg2: signedMsg2 }, + data: { msg2: signedMsg2s }, }; return { from: partyIdToSignatureShareType(partyId), @@ -110,51 +153,66 @@ export async function getSignatureShareRoundTwo( } /** - * Verifies the peer's round-2 PGP signature and returns the raw deserialized - * message ready for `RedPallasDSG.handleIncomingMessages`. + * Verifies the peer's round-2 PGP signatures and returns the raw deserialized + * messages, one per shielded spend in the peer's array order, ready for + * `RedPallasDSG.handleIncomingMessages` (feed `msg2[i]` into spend i's session). + * All-or-nothing: a single failed verification rejects the whole round. */ export async function verifyPeerMessageRoundTwo( parsedRound2Output: RedpallasMPCv2SignatureShareRound2Output, peerGpgKey: openpgp.Key, peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO -): Promise { - const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound2Output.data.msg2, peerGpgKey); - return { - from: peerPartyId, - payload: new Uint8Array(rawBytes), - }; +): Promise { + const verifiedMsgs: RedPallasMPSTypes.DeserializedMessage[] = []; + for (const signedMsg2 of parsedRound2Output.data.msg2) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg2, peerGpgKey); + verifiedMsgs.push({ from: peerPartyId, payload: new Uint8Array(rawBytes) }); + } + return verifiedMsgs; } /** - * Verifies the peer's round-3 PGP signature and returns the raw deserialized - * message ready for `RedPallasDSG.handleIncomingMessages`. + * Verifies the peer's round-3 PGP signatures and returns the raw deserialized + * messages, one per shielded spend in the peer's array order, ready for + * `RedPallasDSG.handleIncomingMessages` (feed `msg3[i]` into spend i's session). + * All-or-nothing: a single failed verification rejects the whole round. */ export async function verifyPeerMessageRoundThree( parsedRound3Output: RedpallasMPCv2SignatureShareRound3Output, peerGpgKey: openpgp.Key, peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO -): Promise { - const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound3Output.data.msg3, peerGpgKey); - return { from: peerPartyId, payload: new Uint8Array(rawBytes) }; +): Promise { + const verifiedMsgs: RedPallasMPSTypes.DeserializedMessage[] = []; + for (const signedMsg3 of parsedRound3Output.data.msg3) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg3, peerGpgKey); + verifiedMsgs.push({ from: peerPartyId, payload: new Uint8Array(rawBytes) }); + } + return verifiedMsgs; } /** - * Builds the round-3 signature share record (final signer message). + * Builds the round-3 signature share record (final signer messages). + * + * PGP-signs each spend's round-3 message and wraps all N of them, in spend + * order, into ONE SignatureShareRecord (`msg3` array). * * There is no corresponding `verifyBitGoMessageRoundThree` because Wallet Platform - * finalises the signing server-side after receiving round 3; the client obtains the - * signed transaction via `sendTxRequest`. + * finalises the signing server-side after receiving round 3; the client obtains + * the signed transaction via `sendTxRequest`. */ export async function getSignatureShareRoundThree( - userMsg3: RedPallasMPSTypes.DeserializedMessage, + userMsg3s: RedPallasMPSTypes.DeserializedMessage[], userGpgPrivKey: openpgp.PrivateKey, partyId: SignerPartyId = MPCv2PartiesEnum.USER, otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO ): Promise { - const signedMsg3 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg3.payload), userGpgPrivKey); + assert(userMsg3s.length >= 1, 'Expected at least 1 round-3 message (one per shielded spend)'); + const signedMsg3s = await Promise.all( + userMsg3s.map((userMsg3) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg3.payload), userGpgPrivKey)) + ); const share: RedpallasMPCv2SignatureShareRound3Input = { type: 'round3Input', - data: { msg3: signedMsg3 }, + data: { msg3: signedMsg3s }, }; return { from: partyIdToSignatureShareType(partyId), @@ -166,19 +224,25 @@ export async function getSignatureShareRoundThree( /** * Builds the rerand round-1 signature share record. * - * Rerand must run before every RedPallas DSG, with the same counterpart. Round 1 - * carries the PGP-signed WASM commitment broadcast (`RedPallasRerand.getFirstMessage()`). + * Rerand must run before every RedPallas DSG, with the same counterpart, once + * per shielded spend: the caller initializes N `RedPallasRerand` sessions on the + * SAME DKG keyshare and passes each session's `getFirstMessage()`, in spend + * order. Round 1 carries the PGP-signed WASM commitments; all N are wrapped + * into ONE SignatureShareRecord (`msg1` array). */ export async function getRerandShareRoundOne( - userMsg1: RedPallasMPSTypes.DeserializedMessage, + userMsg1s: RedPallasMPSTypes.DeserializedMessage[], userGpgPrivKey: openpgp.PrivateKey, partyId: SignerPartyId = MPCv2PartiesEnum.USER, otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO ): Promise { - const signedMsg1 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey); + assert(userMsg1s.length >= 1, 'Expected at least 1 rerand round-1 message (one per shielded spend)'); + const signedMsg1s = await Promise.all( + userMsg1s.map((userMsg1) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey)) + ); const share: RedpallasMPCv2RerandShareRound1Input = { type: 'round1Input', - data: { msg1: signedMsg1 }, + data: { msg1: signedMsg1s }, }; return { from: partyIdToSignatureShareType(partyId), @@ -188,34 +252,44 @@ export async function getRerandShareRoundOne( } /** - * Verifies the peer's rerand round-1 PGP signature and returns the raw deserialized - * message ready for `RedPallasRerand.handleIncomingMessages`. + * Verifies the peer's rerand round-1 PGP signatures and returns the raw + * deserialized messages, one per shielded spend in the peer's array order, + * ready for `RedPallasRerand.handleIncomingMessages` (feed `msg1[i]` into + * spend i's session). All-or-nothing: a single failed verification rejects the + * whole round. */ export async function verifyPeerRerandMessageRoundOne( parsedRound1Output: RedpallasMPCv2RerandShareRound1Output, peerGpgKey: openpgp.Key, peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO -): Promise { - const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, peerGpgKey); - return { - from: peerPartyId, - payload: new Uint8Array(rawBytes), - }; +): Promise { + const verifiedMsgs: RedPallasMPSTypes.DeserializedMessage[] = []; + for (const signedMsg1 of parsedRound1Output.data.msg1) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg1, peerGpgKey); + verifiedMsgs.push({ from: peerPartyId, payload: new Uint8Array(rawBytes) }); + } + return verifiedMsgs; } /** - * Builds the rerand round-2 signature share record (opening message). + * Builds the rerand round-2 signature share record (opening messages). + * + * PGP-signs each spend's rerand round-2 message and wraps all N of them, in + * spend order, into ONE SignatureShareRecord (`msg2` array). */ export async function getRerandShareRoundTwo( - userMsg2: RedPallasMPSTypes.DeserializedMessage, + userMsg2s: RedPallasMPSTypes.DeserializedMessage[], userGpgPrivKey: openpgp.PrivateKey, partyId: SignerPartyId = MPCv2PartiesEnum.USER, otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO ): Promise { - const signedMsg2 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey); + assert(userMsg2s.length >= 1, 'Expected at least 1 rerand round-2 message (one per shielded spend)'); + const signedMsg2s = await Promise.all( + userMsg2s.map((userMsg2) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey)) + ); const share: RedpallasMPCv2RerandShareRound2Input = { type: 'round2Input', - data: { msg2: signedMsg2 }, + data: { msg2: signedMsg2s }, }; return { from: partyIdToSignatureShareType(partyId), @@ -225,19 +299,23 @@ export async function getRerandShareRoundTwo( } /** - * Verifies the peer's rerand round-2 PGP signature and returns the raw deserialized - * message ready for `RedPallasRerand.handleIncomingMessages`. After this exchange - * both parties read the rerandomized keyshare via `getRerandomizedKeyShare()` and - * feed it into `RedPallasDSG.initDsg`. + * Verifies the peer's rerand round-2 PGP signatures and returns the raw + * deserialized messages, one per shielded spend in the peer's array order, + * ready for `RedPallasRerand.handleIncomingMessages` (feed `msg2[i]` into + * spend i's session). All-or-nothing: a single failed verification rejects the + * whole round. After this exchange both parties read each spend's + * rerandomized keyshare via `getRerandomizedKeyShare()` and feed it into that + * spend's `RedPallasDSG.initDsg` - never the bare DKG keyshare. */ export async function verifyPeerRerandMessageRoundTwo( parsedRound2Output: RedpallasMPCv2RerandShareRound2Output, peerGpgKey: openpgp.Key, peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO -): Promise { - const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound2Output.data.msg2, peerGpgKey); - return { - from: peerPartyId, - payload: new Uint8Array(rawBytes), - }; +): Promise { + const verifiedMsgs: RedPallasMPSTypes.DeserializedMessage[] = []; + for (const signedMsg2 of parsedRound2Output.data.msg2) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg2, peerGpgKey); + verifiedMsgs.push({ from: peerPartyId, payload: new Uint8Array(rawBytes) }); + } + return verifiedMsgs; } diff --git a/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts index 47259891d05..b1e9aacf883 100644 --- a/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts @@ -30,25 +30,26 @@ import { decodeWithCodec } from '../../../../../src/bitgo/utils/codecs'; import { generateGPGKeyPair } from '../../../../../src/bitgo/utils/opengpgUtils'; import { MPCv2PartiesEnum } from '../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; +/** N distinct spend payloads for one round, spend-index order baked into the bytes. */ +const payloads = (party: MPCv2PartiesEnum, round: number, n: number): RedPallasMPSTypes.DeserializedMessage[] => + Array.from({ length: n }, (_, spendIndex) => ({ + from: party, + payload: new Uint8Array(Buffer.from(`${party}-round-${round}-spend-${spendIndex}-payload`)), + })); + +const TAMPERED_SIGNATURE = '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n'; + +const tamperedMessage = () => ({ + message: Buffer.from('tampered').toString('base64'), + signature: TAMPERED_SIGNATURE, +}); + describe('RedPallas MPS DSG helper functions', function () { let userGpgPrivKey: pgp.PrivateKey; let backupGpgPrivKey: pgp.PrivateKey; let bitgoGpgPrivKey: pgp.PrivateKey; let bitgoGpgPubKey: pgp.Key; - const userPayload = (round: number): RedPallasMPSTypes.DeserializedMessage => ({ - from: MPCv2PartiesEnum.USER, - payload: new Uint8Array(Buffer.from(`user-round-${round}-payload`)), - }); - const backupPayload = (round: number): RedPallasMPSTypes.DeserializedMessage => ({ - from: MPCv2PartiesEnum.BACKUP, - payload: new Uint8Array(Buffer.from(`backup-round-${round}-payload`)), - }); - const bitgoPayload = (round: number): RedPallasMPSTypes.DeserializedMessage => ({ - from: MPCv2PartiesEnum.BITGO, - payload: new Uint8Array(Buffer.from(`bitgo-round-${round}-payload`)), - }); - before('generate GPG key pairs', async function () { const userGpgKeyPair = await generateGPGKeyPair('ed25519'); const backupGpgKeyPair = await generateGPGKeyPair('ed25519'); @@ -62,8 +63,11 @@ describe('RedPallas MPS DSG helper functions', function () { // ── Round 1 ───────────────────────────────────────────────────────────────── - it('getSignatureShareRoundOne should build a valid round-1 share for the user', async function () { - const share: SignatureShareRecord = await getSignatureShareRoundOne(userPayload(1), userGpgPrivKey); + it('getSignatureShareRoundOne should build a valid N=1 round-1 share for the user', async function () { + const share: SignatureShareRecord = await getSignatureShareRoundOne( + payloads(MPCv2PartiesEnum.USER, 1, 1), + userGpgPrivKey + ); assert.strictEqual(share.from, SignatureShareType.USER); assert.strictEqual(share.to, SignatureShareType.BITGO); @@ -74,13 +78,14 @@ describe('RedPallas MPS DSG helper functions', function () { 'RedpallasMPCv2SignatureShareRound1Input' ); assert.strictEqual(parsed.type, 'round1Input'); - assert.ok(parsed.data.msg1.message, 'msg1.message should be set'); - assert.ok(parsed.data.msg1.signature, 'msg1.signature should be set'); + assert.strictEqual(parsed.data.msg1.length, 1); + assert.ok(parsed.data.msg1[0].message, 'msg1[0].message should be set'); + assert.ok(parsed.data.msg1[0].signature, 'msg1[0].signature should be set'); }); - it('getSignatureShareRoundOne should build a valid round-1 share for the backup', async function () { + it('getSignatureShareRoundOne should build a valid N=1 round-1 share for the backup', async function () { const share: SignatureShareRecord = await getSignatureShareRoundOne( - backupPayload(1), + payloads(MPCv2PartiesEnum.BACKUP, 1, 1), backupGpgPrivKey, MPCv2PartiesEnum.BACKUP ); @@ -89,31 +94,69 @@ describe('RedPallas MPS DSG helper functions', function () { assert.strictEqual(share.to, SignatureShareType.BITGO); }); - it('verifyPeerMessageRoundOne should verify a valid BitGo round-1 message', async function () { - const bitgoSignedMsg1 = await RedPallasMPSComms.detachSignMpsMessage( - Buffer.from(bitgoPayload(1).payload), - bitgoGpgPrivKey + it('getSignatureShareRoundOne N=3 should decode and preserve spend order', async function () { + const userMsg1s = payloads(MPCv2PartiesEnum.USER, 1, 3); + const share: SignatureShareRecord = await getSignatureShareRoundOne(userMsg1s, userGpgPrivKey); + + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound1Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound1Input' + ); + assert.strictEqual(parsed.data.msg1.length, 3); + + const userGpgPubKey = userGpgPrivKey.toPublic(); + for (const [spendIndex, signedMsg1] of parsed.data.msg1.entries()) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg1, userGpgPubKey); + assert.deepStrictEqual(rawBytes, Buffer.from(userMsg1s[spendIndex].payload), `spend ${spendIndex} out of order`); + } + }); + + it('getSignatureShareRoundOne should reject an empty message array', async function () { + await assert.rejects(getSignatureShareRoundOne([], userGpgPrivKey), /Expected at least 1 round-1 message/); + }); + + it('verifyPeerMessageRoundOne should verify valid N=3 BitGo round-1 messages in order', async function () { + const bitgoMsg1s = payloads(MPCv2PartiesEnum.BITGO, 1, 3); + const signedMsg1s = await Promise.all( + bitgoMsg1s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) ); const round1Output: RedpallasMPCv2SignatureShareRound1Output = { type: 'round1Output', - data: { msg1: bitgoSignedMsg1 }, + data: { msg1: signedMsg1s }, }; - const result = await verifyPeerMessageRoundOne(round1Output, bitgoGpgPubKey); + const results = await verifyPeerMessageRoundOne(round1Output, bitgoGpgPubKey); + + assert.strictEqual(results.length, 3); + results.forEach((result, spendIndex) => { + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual( + Buffer.from(result.payload), + Buffer.from(bitgoMsg1s[spendIndex].payload), + `spend ${spendIndex} out of order` + ); + }); + }); - assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); - assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(1).payload)); + it('verifyPeerMessageRoundOne should fail the whole N=3 round when one spend signature is bad', async function () { + const bitgoMsg1s = payloads(MPCv2PartiesEnum.BITGO, 1, 3); + const signedMsg1s = await Promise.all( + bitgoMsg1s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) + ); + signedMsg1s[1] = tamperedMessage(); + const round1Output: RedpallasMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { msg1: signedMsg1s }, + }; + + await assert.rejects(verifyPeerMessageRoundOne(round1Output, bitgoGpgPubKey)); }); it('verifyPeerMessageRoundOne should throw on a tampered round-1 message', async function () { const round1Output: RedpallasMPCv2SignatureShareRound1Output = { type: 'round1Output', - data: { - msg1: { - message: Buffer.from('tampered').toString('base64'), - signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', - }, - }, + data: { msg1: [tamperedMessage()] }, }; await assert.rejects(verifyPeerMessageRoundOne(round1Output, bitgoGpgPubKey)); @@ -121,8 +164,11 @@ describe('RedPallas MPS DSG helper functions', function () { // ── Round 2 ───────────────────────────────────────────────────────────────── - it('getSignatureShareRoundTwo should build a valid round-2 share for the user', async function () { - const share: SignatureShareRecord = await getSignatureShareRoundTwo(userPayload(2), userGpgPrivKey); + it('getSignatureShareRoundTwo should build a valid N=1 round-2 share for the user', async function () { + const share: SignatureShareRecord = await getSignatureShareRoundTwo( + payloads(MPCv2PartiesEnum.USER, 2, 1), + userGpgPrivKey + ); assert.strictEqual(share.from, SignatureShareType.USER); assert.strictEqual(share.to, SignatureShareType.BITGO); @@ -133,35 +179,74 @@ describe('RedPallas MPS DSG helper functions', function () { 'RedpallasMPCv2SignatureShareRound2Input' ); assert.strictEqual(parsed.type, 'round2Input'); - assert.ok(parsed.data.msg2.message, 'msg2.message should be set'); - assert.ok(parsed.data.msg2.signature, 'msg2.signature should be set'); + assert.strictEqual(parsed.data.msg2.length, 1); + assert.ok(parsed.data.msg2[0].message, 'msg2[0].message should be set'); + assert.ok(parsed.data.msg2[0].signature, 'msg2[0].signature should be set'); + }); + + it('getSignatureShareRoundTwo N=3 should decode and preserve spend order', async function () { + const userMsg2s = payloads(MPCv2PartiesEnum.USER, 2, 3); + const share: SignatureShareRecord = await getSignatureShareRoundTwo(userMsg2s, userGpgPrivKey); + + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound2Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound2Input' + ); + assert.strictEqual(parsed.data.msg2.length, 3); + + const userGpgPubKey = userGpgPrivKey.toPublic(); + for (const [spendIndex, signedMsg2] of parsed.data.msg2.entries()) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg2, userGpgPubKey); + assert.deepStrictEqual(rawBytes, Buffer.from(userMsg2s[spendIndex].payload), `spend ${spendIndex} out of order`); + } }); - it('verifyPeerMessageRoundTwo should verify a valid BitGo round-2 message', async function () { - const bitgoSignedMsg2 = await RedPallasMPSComms.detachSignMpsMessage( - Buffer.from(bitgoPayload(2).payload), - bitgoGpgPrivKey + it('getSignatureShareRoundTwo should reject an empty message array', async function () { + await assert.rejects(getSignatureShareRoundTwo([], userGpgPrivKey), /Expected at least 1 round-2 message/); + }); + + it('verifyPeerMessageRoundTwo should verify valid N=3 BitGo round-2 messages in order', async function () { + const bitgoMsg2s = payloads(MPCv2PartiesEnum.BITGO, 2, 3); + const signedMsg2s = await Promise.all( + bitgoMsg2s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) ); const round2Output: RedpallasMPCv2SignatureShareRound2Output = { type: 'round2Output', - data: { msg2: bitgoSignedMsg2 }, + data: { msg2: signedMsg2s }, }; - const result = await verifyPeerMessageRoundTwo(round2Output, bitgoGpgPubKey); + const results = await verifyPeerMessageRoundTwo(round2Output, bitgoGpgPubKey); + + assert.strictEqual(results.length, 3); + results.forEach((result, spendIndex) => { + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual( + Buffer.from(result.payload), + Buffer.from(bitgoMsg2s[spendIndex].payload), + `spend ${spendIndex} out of order` + ); + }); + }); - assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); - assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(2).payload)); + it('verifyPeerMessageRoundTwo should fail the whole N=3 round when one spend signature is bad', async function () { + const bitgoMsg2s = payloads(MPCv2PartiesEnum.BITGO, 2, 3); + const signedMsg2s = await Promise.all( + bitgoMsg2s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) + ); + signedMsg2s[2] = tamperedMessage(); + const round2Output: RedpallasMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { msg2: signedMsg2s }, + }; + + await assert.rejects(verifyPeerMessageRoundTwo(round2Output, bitgoGpgPubKey)); }); it('verifyPeerMessageRoundTwo should throw on a tampered round-2 message', async function () { const round2Output: RedpallasMPCv2SignatureShareRound2Output = { type: 'round2Output', - data: { - msg2: { - message: Buffer.from('tampered').toString('base64'), - signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', - }, - }, + data: { msg2: [tamperedMessage()] }, }; await assert.rejects(verifyPeerMessageRoundTwo(round2Output, bitgoGpgPubKey)); @@ -169,9 +254,9 @@ describe('RedPallas MPS DSG helper functions', function () { // ── Round 3 ───────────────────────────────────────────────────────────────── - it('getSignatureShareRoundThree should build a valid round-3 share for the backup', async function () { + it('getSignatureShareRoundThree should build a valid N=1 round-3 share for the backup', async function () { const share: SignatureShareRecord = await getSignatureShareRoundThree( - backupPayload(3), + payloads(MPCv2PartiesEnum.BACKUP, 3, 1), backupGpgPrivKey, MPCv2PartiesEnum.BACKUP ); @@ -185,44 +270,86 @@ describe('RedPallas MPS DSG helper functions', function () { 'RedpallasMPCv2SignatureShareRound3Input' ); assert.strictEqual(parsed.type, 'round3Input'); - assert.ok(parsed.data.msg3.message, 'msg3.message should be set'); - assert.ok(parsed.data.msg3.signature, 'msg3.signature should be set'); + assert.strictEqual(parsed.data.msg3.length, 1); + assert.ok(parsed.data.msg3[0].message, 'msg3[0].message should be set'); + assert.ok(parsed.data.msg3[0].signature, 'msg3[0].signature should be set'); + }); + + it('getSignatureShareRoundThree N=3 should decode and preserve spend order', async function () { + const userMsg3s = payloads(MPCv2PartiesEnum.USER, 3, 3); + const share: SignatureShareRecord = await getSignatureShareRoundThree(userMsg3s, userGpgPrivKey); + + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound3Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound3Input' + ); + assert.strictEqual(parsed.data.msg3.length, 3); + + const userGpgPubKey = userGpgPrivKey.toPublic(); + for (const [spendIndex, signedMsg3] of parsed.data.msg3.entries()) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg3, userGpgPubKey); + assert.deepStrictEqual(rawBytes, Buffer.from(userMsg3s[spendIndex].payload), `spend ${spendIndex} out of order`); + } }); - it('verifyPeerMessageRoundThree should verify a valid BitGo round-3 message', async function () { - const bitgoSignedMsg3 = await RedPallasMPSComms.detachSignMpsMessage( - Buffer.from(bitgoPayload(3).payload), - bitgoGpgPrivKey + it('getSignatureShareRoundThree should reject an empty message array', async function () { + await assert.rejects(getSignatureShareRoundThree([], userGpgPrivKey), /Expected at least 1 round-3 message/); + }); + + it('verifyPeerMessageRoundThree should verify valid N=3 BitGo round-3 messages in order', async function () { + const bitgoMsg3s = payloads(MPCv2PartiesEnum.BITGO, 3, 3); + const signedMsg3s = await Promise.all( + bitgoMsg3s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) ); const round3Output: RedpallasMPCv2SignatureShareRound3Output = { type: 'round3Output', - data: { msg3: bitgoSignedMsg3 }, + data: { msg3: signedMsg3s }, }; - const result = await verifyPeerMessageRoundThree(round3Output, bitgoGpgPubKey); + const results = await verifyPeerMessageRoundThree(round3Output, bitgoGpgPubKey); + + assert.strictEqual(results.length, 3); + results.forEach((result, spendIndex) => { + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual( + Buffer.from(result.payload), + Buffer.from(bitgoMsg3s[spendIndex].payload), + `spend ${spendIndex} out of order` + ); + }); + }); + + it('verifyPeerMessageRoundThree should fail the whole N=3 round when one spend signature is bad', async function () { + const bitgoMsg3s = payloads(MPCv2PartiesEnum.BITGO, 3, 3); + const signedMsg3s = await Promise.all( + bitgoMsg3s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) + ); + signedMsg3s[0] = tamperedMessage(); + const round3Output: RedpallasMPCv2SignatureShareRound3Output = { + type: 'round3Output', + data: { msg3: signedMsg3s }, + }; - assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); - assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(3).payload)); + await assert.rejects(verifyPeerMessageRoundThree(round3Output, bitgoGpgPubKey)); }); it('verifyPeerMessageRoundThree should throw on a tampered round-3 message', async function () { const round3Output: RedpallasMPCv2SignatureShareRound3Output = { type: 'round3Output', - data: { - msg3: { - message: Buffer.from('tampered').toString('base64'), - signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', - }, - }, + data: { msg3: [tamperedMessage()] }, }; await assert.rejects(verifyPeerMessageRoundThree(round3Output, bitgoGpgPubKey)); }); - // ── Rerand (must run before every DSG, same 2 parties) ────────────────────── + // ── Rerand (must run before every DSG, same 2 parties, once per spend) ────── - it('getRerandShareRoundOne should build a valid rerand round-1 share for the user', async function () { - const share: SignatureShareRecord = await getRerandShareRoundOne(userPayload(1), userGpgPrivKey); + it('getRerandShareRoundOne should build a valid N=1 rerand round-1 share for the user', async function () { + const share: SignatureShareRecord = await getRerandShareRoundOne( + payloads(MPCv2PartiesEnum.USER, 1, 1), + userGpgPrivKey + ); assert.strictEqual(share.from, SignatureShareType.USER); assert.strictEqual(share.to, SignatureShareType.BITGO); @@ -233,13 +360,14 @@ describe('RedPallas MPS DSG helper functions', function () { 'RedpallasMPCv2RerandShareRound1Input' ); assert.strictEqual(parsed.type, 'round1Input'); - assert.ok(parsed.data.msg1.message, 'msg1.message should be set'); - assert.ok(parsed.data.msg1.signature, 'msg1.signature should be set'); + assert.strictEqual(parsed.data.msg1.length, 1); + assert.ok(parsed.data.msg1[0].message, 'msg1[0].message should be set'); + assert.ok(parsed.data.msg1[0].signature, 'msg1[0].signature should be set'); }); - it('getRerandShareRoundOne should build a valid rerand round-1 share for the backup', async function () { + it('getRerandShareRoundOne should build a valid N=1 rerand round-1 share for the backup', async function () { const share: SignatureShareRecord = await getRerandShareRoundOne( - backupPayload(1), + payloads(MPCv2PartiesEnum.BACKUP, 1, 1), backupGpgPrivKey, MPCv2PartiesEnum.BACKUP ); @@ -248,38 +376,79 @@ describe('RedPallas MPS DSG helper functions', function () { assert.strictEqual(share.to, SignatureShareType.BITGO); }); - it('verifyPeerRerandMessageRoundOne should verify a valid BitGo rerand round-1 message', async function () { - const bitgoSignedMsg1 = await RedPallasMPSComms.detachSignMpsMessage( - Buffer.from(bitgoPayload(1).payload), - bitgoGpgPrivKey + it('getRerandShareRoundOne N=3 should decode and preserve spend order', async function () { + const userMsg1s = payloads(MPCv2PartiesEnum.USER, 1, 3); + const share: SignatureShareRecord = await getRerandShareRoundOne(userMsg1s, userGpgPrivKey); + + const parsed = decodeWithCodec( + RedpallasMPCv2RerandShareRound1Input, + JSON.parse(share.share), + 'RedpallasMPCv2RerandShareRound1Input' + ); + assert.strictEqual(parsed.data.msg1.length, 3); + + const userGpgPubKey = userGpgPrivKey.toPublic(); + for (const [spendIndex, signedMsg1] of parsed.data.msg1.entries()) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg1, userGpgPubKey); + assert.deepStrictEqual(rawBytes, Buffer.from(userMsg1s[spendIndex].payload), `spend ${spendIndex} out of order`); + } + }); + + it('getRerandShareRoundOne should reject an empty message array', async function () { + await assert.rejects(getRerandShareRoundOne([], userGpgPrivKey), /Expected at least 1 rerand round-1 message/); + }); + + it('verifyPeerRerandMessageRoundOne should verify valid N=3 BitGo rerand round-1 messages in order', async function () { + const bitgoMsg1s = payloads(MPCv2PartiesEnum.BITGO, 1, 3); + const signedMsg1s = await Promise.all( + bitgoMsg1s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) ); const round1Output: RedpallasMPCv2RerandShareRound1Output = { type: 'round1Output', - data: { msg1: bitgoSignedMsg1 }, + data: { msg1: signedMsg1s }, }; - const result = await verifyPeerRerandMessageRoundOne(round1Output, bitgoGpgPubKey); + const results = await verifyPeerRerandMessageRoundOne(round1Output, bitgoGpgPubKey); + + assert.strictEqual(results.length, 3); + results.forEach((result, spendIndex) => { + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual( + Buffer.from(result.payload), + Buffer.from(bitgoMsg1s[spendIndex].payload), + `spend ${spendIndex} out of order` + ); + }); + }); + + it('verifyPeerRerandMessageRoundOne should fail the whole N=3 round when one spend signature is bad', async function () { + const bitgoMsg1s = payloads(MPCv2PartiesEnum.BITGO, 1, 3); + const signedMsg1s = await Promise.all( + bitgoMsg1s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) + ); + signedMsg1s[1] = tamperedMessage(); + const round1Output: RedpallasMPCv2RerandShareRound1Output = { + type: 'round1Output', + data: { msg1: signedMsg1s }, + }; - assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); - assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(1).payload)); + await assert.rejects(verifyPeerRerandMessageRoundOne(round1Output, bitgoGpgPubKey)); }); it('verifyPeerRerandMessageRoundOne should throw on a tampered rerand round-1 message', async function () { const round1Output: RedpallasMPCv2RerandShareRound1Output = { type: 'round1Output', - data: { - msg1: { - message: Buffer.from('tampered').toString('base64'), - signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', - }, - }, + data: { msg1: [tamperedMessage()] }, }; await assert.rejects(verifyPeerRerandMessageRoundOne(round1Output, bitgoGpgPubKey)); }); - it('getRerandShareRoundTwo should build a valid rerand round-2 share for the user', async function () { - const share: SignatureShareRecord = await getRerandShareRoundTwo(userPayload(2), userGpgPrivKey); + it('getRerandShareRoundTwo should build a valid N=1 rerand round-2 share for the user', async function () { + const share: SignatureShareRecord = await getRerandShareRoundTwo( + payloads(MPCv2PartiesEnum.USER, 2, 1), + userGpgPrivKey + ); assert.strictEqual(share.from, SignatureShareType.USER); assert.strictEqual(share.to, SignatureShareType.BITGO); @@ -290,35 +459,74 @@ describe('RedPallas MPS DSG helper functions', function () { 'RedpallasMPCv2RerandShareRound2Input' ); assert.strictEqual(parsed.type, 'round2Input'); - assert.ok(parsed.data.msg2.message, 'msg2.message should be set'); - assert.ok(parsed.data.msg2.signature, 'msg2.signature should be set'); + assert.strictEqual(parsed.data.msg2.length, 1); + assert.ok(parsed.data.msg2[0].message, 'msg2[0].message should be set'); + assert.ok(parsed.data.msg2[0].signature, 'msg2[0].signature should be set'); }); - it('verifyPeerRerandMessageRoundTwo should verify a valid BitGo rerand round-2 message', async function () { - const bitgoSignedMsg2 = await RedPallasMPSComms.detachSignMpsMessage( - Buffer.from(bitgoPayload(2).payload), - bitgoGpgPrivKey + it('getRerandShareRoundTwo N=3 should decode and preserve spend order', async function () { + const userMsg2s = payloads(MPCv2PartiesEnum.USER, 2, 3); + const share: SignatureShareRecord = await getRerandShareRoundTwo(userMsg2s, userGpgPrivKey); + + const parsed = decodeWithCodec( + RedpallasMPCv2RerandShareRound2Input, + JSON.parse(share.share), + 'RedpallasMPCv2RerandShareRound2Input' + ); + assert.strictEqual(parsed.data.msg2.length, 3); + + const userGpgPubKey = userGpgPrivKey.toPublic(); + for (const [spendIndex, signedMsg2] of parsed.data.msg2.entries()) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg2, userGpgPubKey); + assert.deepStrictEqual(rawBytes, Buffer.from(userMsg2s[spendIndex].payload), `spend ${spendIndex} out of order`); + } + }); + + it('getRerandShareRoundTwo should reject an empty message array', async function () { + await assert.rejects(getRerandShareRoundTwo([], userGpgPrivKey), /Expected at least 1 rerand round-2 message/); + }); + + it('verifyPeerRerandMessageRoundTwo should verify valid N=3 BitGo rerand round-2 messages in order', async function () { + const bitgoMsg2s = payloads(MPCv2PartiesEnum.BITGO, 2, 3); + const signedMsg2s = await Promise.all( + bitgoMsg2s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) ); const round2Output: RedpallasMPCv2RerandShareRound2Output = { type: 'round2Output', - data: { msg2: bitgoSignedMsg2 }, + data: { msg2: signedMsg2s }, }; - const result = await verifyPeerRerandMessageRoundTwo(round2Output, bitgoGpgPubKey); + const results = await verifyPeerRerandMessageRoundTwo(round2Output, bitgoGpgPubKey); + + assert.strictEqual(results.length, 3); + results.forEach((result, spendIndex) => { + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual( + Buffer.from(result.payload), + Buffer.from(bitgoMsg2s[spendIndex].payload), + `spend ${spendIndex} out of order` + ); + }); + }); - assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); - assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(2).payload)); + it('verifyPeerRerandMessageRoundTwo should fail the whole N=3 round when one spend signature is bad', async function () { + const bitgoMsg2s = payloads(MPCv2PartiesEnum.BITGO, 2, 3); + const signedMsg2s = await Promise.all( + bitgoMsg2s.map((msg) => RedPallasMPSComms.detachSignMpsMessage(Buffer.from(msg.payload), bitgoGpgPrivKey)) + ); + signedMsg2s[2] = tamperedMessage(); + const round2Output: RedpallasMPCv2RerandShareRound2Output = { + type: 'round2Output', + data: { msg2: signedMsg2s }, + }; + + await assert.rejects(verifyPeerRerandMessageRoundTwo(round2Output, bitgoGpgPubKey)); }); it('verifyPeerRerandMessageRoundTwo should throw on a tampered rerand round-2 message', async function () { const round2Output: RedpallasMPCv2RerandShareRound2Output = { type: 'round2Output', - data: { - msg2: { - message: Buffer.from('tampered').toString('base64'), - signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', - }, - }, + data: { msg2: [tamperedMessage()] }, }; await assert.rejects(verifyPeerRerandMessageRoundTwo(round2Output, bitgoGpgPubKey)); @@ -326,8 +534,9 @@ describe('RedPallas MPS DSG helper functions', function () { // ── Envelope round-trip via the independent RedPallas MPS comms layer ─────── - it('a share built by getSignatureShareRoundOne verifies against RedPallasMPSComms directly', async function () { - const share = await getSignatureShareRoundOne(userPayload(1), userGpgPrivKey); + it('N=3 shares built by getSignatureShareRoundOne verify against RedPallasMPSComms directly', async function () { + const userMsg1s = payloads(MPCv2PartiesEnum.USER, 1, 3); + const share = await getSignatureShareRoundOne(userMsg1s, userGpgPrivKey); const parsed = decodeWithCodec( RedpallasMPCv2SignatureShareRound1Input, JSON.parse(share.share), @@ -335,8 +544,9 @@ describe('RedPallas MPS DSG helper functions', function () { ); const userGpgPubKey = userGpgPrivKey.toPublic(); - const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsed.data.msg1, userGpgPubKey); - - assert.deepStrictEqual(rawBytes, Buffer.from(userPayload(1).payload)); + for (const [spendIndex, signedMsg1] of parsed.data.msg1.entries()) { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(signedMsg1, userGpgPubKey); + assert.deepStrictEqual(rawBytes, Buffer.from(userMsg1s[spendIndex].payload)); + } }); }); diff --git a/modules/sdk-lib-mpc/test/unit/tss/redpallas/rerand.ts b/modules/sdk-lib-mpc/test/unit/tss/redpallas/rerand.ts index 1b6dbec3f7e..30ec209f56b 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/redpallas/rerand.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/redpallas/rerand.ts @@ -283,6 +283,63 @@ describe('RedPallas MPS Rerand', function () { }); }); + // ── Multi-spend fan-out (WCI-1949) ────────────────────────────────────────── + // One rerand session per Orchard spend, all seeded from the SAME DKG keyshare, + // driven in lockstep exactly like the OVC signer drives them per round. This + // validates the per-spend fan-out premise: instances are fully independent (no + // shared/static state) and each spend gets its own distinct rerand triple. + describe('Multi-spend fan-out (WCI-1949)', function () { + const N = 3; + + it('should run 3 concurrent rerand sessions over the same DKG keyshare with distinct per-spend results', async function () { + const userSessions = Array.from({ length: N }, () => new RedPallasMPSRerand.RedPallasRerand(0)); + const bitgoSessions = Array.from({ length: N }, () => new RedPallasMPSRerand.RedPallasRerand(2)); + + // All N user sessions init from the SAME DKG keyshare (WP does the same server-side). + await Promise.all([ + ...userSessions.map((session) => session.initRerand(userKeyShare, 2)), + ...bitgoSessions.map((session) => session.initRerand(bitgoKeyShare, 0)), + ]); + + // Round 0 (local commitments), interleaved across all spends. + const userMsg1s = userSessions.map((session) => session.getFirstMessage()); + const bitgoMsg1s = bitgoSessions.map((session) => session.getFirstMessage()); + + // Mid-flight export/restore of spend 1's user session: concurrent sessions + // must survive persistence independently. + const exportedSession = userSessions[1].getSession(); + const restoredSession = new RedPallasMPSRerand.RedPallasRerand(0); + await restoredSession.restoreSession(exportedSession); + userSessions[1] = restoredSession; + + // Round 1 (commit), interleaved. + const userMsg2s = userSessions.map( + (session, spend) => session.handleIncomingMessages([userMsg1s[spend], bitgoMsg1s[spend]])[0] + ); + const bitgoMsg2s = bitgoSessions.map( + (session, spend) => session.handleIncomingMessages([bitgoMsg1s[spend], userMsg1s[spend]])[0] + ); + + // Round 2 (open), interleaved; every session completes. + userSessions.forEach((session, spend) => session.handleIncomingMessages([userMsg2s[spend], bitgoMsg2s[spend]])); + bitgoSessions.forEach((session, spend) => session.handleIncomingMessages([bitgoMsg2s[spend], userMsg2s[spend]])); + + const userResults = userSessions.map((session) => session.getRerandomizedKeyShare()); + const bitgoResults = bitgoSessions.map((session) => session.getRerandomizedKeyShare()); + + // Each user/bitgo pair must agree on its own spend's pk and alpha. + for (let spend = 0; spend < N; spend++) { + assertRerandResult(userResults[spend], bitgoResults[spend], dkgPublicKey); + } + + // Each spend must have its OWN pk/alpha: all pairwise distinct. + const pks = userResults.map((result) => result.pk.toString('hex')); + const alphas = userResults.map((result) => result.alpha.toString('hex')); + assert.strictEqual(new Set(pks).size, N, 'each spend must get a distinct rerand pk'); + assert.strictEqual(new Set(alphas).size, N, 'each spend must get a distinct rerand alpha'); + }); + }); + function assertRerandResult(result1: RedPallasRerandResult, result2: RedPallasRerandResult, dkgPub: Buffer): void { assert.strictEqual(result1.pk.length, 32, 'pk must be 32 bytes'); assert.strictEqual(result1.alpha.length, 32, 'alpha must be 32 bytes'); diff --git a/yarn.lock b/yarn.lock index 4cfe70cf140..778d4de95ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1032,6 +1032,17 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" +"@bitgo/public-types@6.91.0": + version "6.91.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.91.0.tgz#b67349150359bf648bd878a1a4382c9e151ed673" + integrity sha512-aDonUKK48GQApzyF9+LTs6aVNEyW0IO42rxNj9lvBFjkHyayBbBRaoY7ieWF1FUKLv+0eGwjjS3/WfRo6BfCgA== + dependencies: + fp-ts "^2.0.0" + io-ts "npm:@bitgo-forks/io-ts@2.1.4" + io-ts-types "^0.5.16" + monocle-ts "^2.3.13" + newtype-ts "^0.3.5" + "@bitgo/wasm-dot@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz"