From 3d581dec3fbb99a082e09d5d84be066172f9c81b Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Tue, 5 May 2026 15:11:52 +0530 Subject: [PATCH] feat(sdk-core): add EdDSA MPCv2 DSG helpers and DKG key-share util - Add getSignatureShareRoundOne/Two/Three helpers in sdk-core for building PGP-signed MPS broadcast messages per signing round - Add verifyBitGoMessageRoundOne/Two helpers for verifying peer PGP signatures and deserialising incoming MPS messages - Parameterise partyId (default: USER=0) and peerPartyId (type: MPCv2PartiesEnum, default: BITGO=2) to support non-user signers without hardcoding - Wire eddsaMpcV2 type in sendSignatureShareV2 (common.ts) - Add generateEdDsaDKGKeyShares to sdk-lib-mpc MPSUtil with split EdDsaDKGPartySeed (encKey / dkgSeed) to eliminate seed dual-use; add length assertions - Replace duplicate test-util copy with a re-export from the production source - Add unit tests covering all helpers including BACKUP party path, tampered-message rejection for both round-1 and round-2 verify, and deterministic-seed behaviour Co-Authored-By: Claude Sonnet 4.6 TICKET: WCI-153 --- modules/sdk-core/src/bitgo/tss/common.ts | 2 + .../src/bitgo/tss/eddsa/eddsaMPCv2.ts | 126 +++++++ modules/sdk-core/src/index.ts | 2 + .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 325 ++++++++++++++++++ modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts | 67 ++++ .../sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts | 26 +- .../test/unit/tss/eddsa/eddsa-utils.ts | 39 ++- .../sdk-lib-mpc/test/unit/tss/eddsa/util.ts | 59 +--- 8 files changed, 582 insertions(+), 64 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts diff --git a/modules/sdk-core/src/bitgo/tss/common.ts b/modules/sdk-core/src/bitgo/tss/common.ts index 22a34048e6..0a294980d6 100644 --- a/modules/sdk-core/src/bitgo/tss/common.ts +++ b/modules/sdk-core/src/bitgo/tss/common.ts @@ -131,6 +131,8 @@ export async function sendSignatureShareV2( let type = ''; if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'ecdsa') { type = 'ecdsaMpcV2'; + } else if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'eddsa') { + type = 'eddsaMpcV2'; } else if (multisigTypeVersion === undefined && mpcAlgorithm === 'eddsa') { type = 'eddsaMpcV1'; } diff --git a/modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts new file mode 100644 index 0000000000..4e0a62ba4f --- /dev/null +++ b/modules/sdk-core/src/bitgo/tss/eddsa/eddsaMPCv2.ts @@ -0,0 +1,126 @@ +import assert from 'assert'; +import * as openpgp from 'openpgp'; +import { MPSComms, MPSTypes } from '@bitgo/sdk-lib-mpc'; +import { + EddsaMPCv2SignatureShareRound1Input, + EddsaMPCv2SignatureShareRound1Output, + EddsaMPCv2SignatureShareRound2Input, + EddsaMPCv2SignatureShareRound2Output, + EddsaMPCv2SignatureShareRound3Input, +} from '@bitgo/public-types'; +import { SignatureShareRecord, SignatureShareType } from '../../utils/tss/baseTypes'; +import { MPCv2PartiesEnum } from '../../utils/tss/ecdsa/typesMPCv2'; + +function partyIdToSignatureShareType(partyId: 0 | 1 | 2): SignatureShareType { + assert(partyId === 0 || partyId === 1 || partyId === 2, 'Invalid partyId for EdDSA MPCv2 signing'); + switch (partyId) { + case 0: + return SignatureShareType.USER; + case 1: + return SignatureShareType.BACKUP; + case 2: + return SignatureShareType.BITGO; + } +} + +/** + * 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`. + */ +export async function getSignatureShareRoundOne( + userMsg1: MPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: 0 | 1 = 0, + otherSignerPartyId: 0 | 1 | 2 = 2 +): Promise { + const signedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey); + const share: EddsaMPCv2SignatureShareRound1Input = { + type: 'round1Input', + data: { msg1: signedMsg1 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} + +/** + * Verifies the peer's round-1 PGP signature and returns the raw deserialized + * message ready for `DSG.handleIncomingMessages`. + */ +export async function verifyBitGoMessageRoundOne( + parsedRound1Output: EddsaMPCv2SignatureShareRound1Output, + peerGpgKey: openpgp.Key, + peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const rawBytes = await MPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, peerGpgKey); + return { + from: peerPartyId, + payload: new Uint8Array(rawBytes), + }; +} + +/** + * Builds the round-2 signature share record. + */ +export async function getSignatureShareRoundTwo( + userMsg2: MPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: 0 | 1 = 0, + otherSignerPartyId: 0 | 1 | 2 = 2 +): Promise { + const signedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey); + const share: EddsaMPCv2SignatureShareRound2Input = { + type: 'round2Input', + data: { msg2: signedMsg2 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} + +/** + * Verifies the peer's round-2 PGP signature and returns the raw deserialized + * message ready for `DSG.handleIncomingMessages`. + */ +export async function verifyBitGoMessageRoundTwo( + parsedRound2Output: EddsaMPCv2SignatureShareRound2Output, + peerGpgKey: openpgp.Key, + peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const rawBytes = await MPSComms.verifyMpsMessage(parsedRound2Output.data.msg2, peerGpgKey); + return { + from: peerPartyId, + payload: new Uint8Array(rawBytes), + }; +} + +/** + * Builds the round-3 signature share record (final signer message). + * + * 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`. + */ +export async function getSignatureShareRoundThree( + userMsg3: MPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: 0 | 1 = 0, + otherSignerPartyId: 0 | 1 | 2 = 2 +): Promise { + const signedMsg3 = await MPSComms.detachSignMpsMessage(Buffer.from(userMsg3.payload), userGpgPrivKey); + const share: EddsaMPCv2SignatureShareRound3Input = { + type: 'round3Input', + data: { msg3: signedMsg3 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index c327b9ae07..ac3aeda639 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -10,6 +10,8 @@ import { EcdsaUtils } from './bitgo/utils/tss/ecdsa/ecdsa'; export { EcdsaUtils }; import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2'; export { EcdsaMPCv2Utils }; +import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2'; +export { EddsaMPCv2Utils }; export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification'; export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types'; export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types'; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts new file mode 100644 index 0000000000..b228d1be96 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -0,0 +1,325 @@ +import * as assert from 'assert'; +import * as pgp from 'openpgp'; +import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc'; +import { + EddsaMPCv2SignatureShareRound1Input, + EddsaMPCv2SignatureShareRound1Output, + EddsaMPCv2SignatureShareRound2Input, + EddsaMPCv2SignatureShareRound2Output, + EddsaMPCv2SignatureShareRound3Input, +} from '@bitgo/public-types'; +import { SignatureShareRecord, SignatureShareType } from '../../../../../../src'; +import { + getSignatureShareRoundOne, + getSignatureShareRoundTwo, + getSignatureShareRoundThree, + verifyBitGoMessageRoundOne, + verifyBitGoMessageRoundTwo, +} from '../../../../../../src/bitgo/tss/eddsa/eddsaMPCv2'; +import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs'; +import { generateGPGKeyPair } from '../../../../../../src/bitgo/utils/opengpgUtils'; +import { MPCv2PartiesEnum } from '../../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; + +describe('EdDSA MPS DSG helper functions', async () => { + let userKeyShare: Buffer; + let backupKeyShare: Buffer; + let bitgoKeyShare: Buffer; + let userGpgPrivKey: pgp.PrivateKey; + let backupGpgPrivKey: pgp.PrivateKey; + let bitgoGpgPrivKey: pgp.PrivateKey; + let bitgoGpgPubKey: pgp.Key; + + const signableHex = 'deadbeef'; + const derivationPath = 'm/0'; + + before('generate EdDSA DKG key shares', async () => { + const userGpgKeyPair = await generateGPGKeyPair('ed25519'); + const backupGpgKeyPair = await generateGPGKeyPair('ed25519'); + const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + + userGpgPrivKey = await pgp.readPrivateKey({ armoredKey: userGpgKeyPair.privateKey }); + backupGpgPrivKey = await pgp.readPrivateKey({ armoredKey: backupGpgKeyPair.privateKey }); + bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + bitgoGpgPubKey = await pgp.readKey({ armoredKey: bitgoGpgKeyPair.publicKey }); + + const [userDkg, backupDkg, bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + userKeyShare = userDkg.getKeyShare(); + backupKeyShare = backupDkg.getKeyShare(); + bitgoKeyShare = bitgoDkg.getKeyShare(); + }); + + // ── Round 1 ───────────────────────────────────────────────────────────────── + + it('getSignatureShareRoundOne should build a valid round-1 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const share: SignatureShareRecord = await getSignatureShareRoundOne(userMsg1, userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound1Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound1Input' + ); + 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'); + }); + + it('getSignatureShareRoundOne should build a valid backup round-1 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const backupDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BACKUP); + backupDsg.initDsg(backupKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const backupMsg1 = backupDsg.getFirstMessage(); + + const share: SignatureShareRecord = await getSignatureShareRoundOne( + backupMsg1, + backupGpgPrivKey, + MPCv2PartiesEnum.BACKUP + ); + + assert.strictEqual(share.from, SignatureShareType.BACKUP); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound1Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound1Input' + ); + 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'); + }); + + it('verifyBitGoMessageRoundOne should verify a valid BitGo round-1 message', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const round1Output: EddsaMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { msg1: bitgoSignedMsg1 }, + }; + + const result = await verifyBitGoMessageRoundOne(round1Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.ok(result.payload.length > 0, 'payload should be non-empty'); + }); + + it('verifyBitGoMessageRoundOne should throw on a tampered message', async () => { + const round1Output: EddsaMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { + msg1: { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }, + }, + }; + + await assert.rejects(verifyBitGoMessageRoundOne(round1Output, bitgoGpgPubKey), 'should throw on invalid signature'); + }); + + // ── Round 2 ───────────────────────────────────────────────────────────────── + + it('getSignatureShareRoundTwo should build a valid round-2 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg1 = await verifyBitGoMessageRoundOne( + { type: 'round1Output', data: { msg1: bitgoSignedMsg1 } }, + bitgoGpgPubKey + ); + const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]); + + const share: SignatureShareRecord = await getSignatureShareRoundTwo(userMsg2, userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound2Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound2Input' + ); + 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'); + }); + + it('getSignatureShareRoundTwo should build a valid backup round-2 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const backupDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BACKUP); + backupDsg.initDsg(backupKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const backupMsg1 = backupDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BACKUP); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg1 = await verifyBitGoMessageRoundOne( + { type: 'round1Output', data: { msg1: bitgoSignedMsg1 } }, + bitgoGpgPubKey + ); + const [backupMsg2] = backupDsg.handleIncomingMessages([backupMsg1, bitgoDeserializedMsg1]); + + const share: SignatureShareRecord = await getSignatureShareRoundTwo( + backupMsg2, + backupGpgPrivKey, + MPCv2PartiesEnum.BACKUP + ); + + assert.strictEqual(share.from, SignatureShareType.BACKUP); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound2Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound2Input' + ); + 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'); + }); + + it('verifyBitGoMessageRoundTwo should verify a valid BitGo round-2 message', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]); + const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); + + const round2Output: EddsaMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { msg2: bitgoSignedMsg2 }, + }; + + const result = await verifyBitGoMessageRoundTwo(round2Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.ok(result.payload.length > 0, 'payload should be non-empty'); + }); + + it('verifyBitGoMessageRoundTwo should throw on a tampered message', async () => { + const round2Output: EddsaMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { + msg2: { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }, + }, + }; + + await assert.rejects(verifyBitGoMessageRoundTwo(round2Output, bitgoGpgPubKey), 'should throw on invalid signature'); + }); + + // ── Round 3 ───────────────────────────────────────────────────────────────── + + it('getSignatureShareRoundThree should build a valid round-3 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.initDsg(userKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const userMsg1 = userDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + // Advance to round 2 + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg1 = await verifyBitGoMessageRoundOne( + { type: 'round1Output', data: { msg1: bitgoSignedMsg1 } }, + bitgoGpgPubKey + ); + const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]); + + const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]); + const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg2 = await verifyBitGoMessageRoundTwo( + { type: 'round2Output', data: { msg2: bitgoSignedMsg2 } }, + bitgoGpgPubKey + ); + const [userMsg3] = userDsg.handleIncomingMessages([userMsg2, bitgoDeserializedMsg2]); + + const share: SignatureShareRecord = await getSignatureShareRoundThree(userMsg3, userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound3Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound3Input' + ); + 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'); + }); + + it('getSignatureShareRoundThree should build a valid backup round-3 share', async () => { + const messageBuffer = Buffer.from(signableHex, 'hex'); + const backupDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BACKUP); + backupDsg.initDsg(backupKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BITGO); + const backupMsg1 = backupDsg.getFirstMessage(); + + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.BACKUP); + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg1 = await verifyBitGoMessageRoundOne( + { type: 'round1Output', data: { msg1: bitgoSignedMsg1 } }, + bitgoGpgPubKey + ); + const [backupMsg2] = backupDsg.handleIncomingMessages([backupMsg1, bitgoDeserializedMsg1]); + + const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, backupMsg1]); + const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); + const bitgoDeserializedMsg2 = await verifyBitGoMessageRoundTwo( + { type: 'round2Output', data: { msg2: bitgoSignedMsg2 } }, + bitgoGpgPubKey + ); + const [backupMsg3] = backupDsg.handleIncomingMessages([backupMsg2, bitgoDeserializedMsg2]); + + const share: SignatureShareRecord = await getSignatureShareRoundThree( + backupMsg3, + backupGpgPrivKey, + MPCv2PartiesEnum.BACKUP + ); + + assert.strictEqual(share.from, SignatureShareType.BACKUP); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + EddsaMPCv2SignatureShareRound3Input, + JSON.parse(share.share), + 'EddsaMPCv2SignatureShareRound3Input' + ); + 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'); + }); +}); diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts index 05b68a979c..f97d06a152 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/util.ts @@ -1,3 +1,8 @@ +import crypto from 'crypto'; +import assert from 'assert'; +import { x25519 } from '@noble/curves/ed25519'; +import { DKG } from './dkg'; + /** * Concatenates multiple Uint8Array instances into a single Uint8Array * @param chunks - Array of Uint8Array instances to concatenate @@ -7,3 +12,65 @@ export function concatBytes(chunks: Uint8Array[]): Uint8Array { const buffers = chunks.map((chunk) => Buffer.from(chunk)); return new Uint8Array(Buffer.concat(buffers)); } + +function generateX25519Keypair(seed?: Buffer): { privKey: Buffer; pubKey: Buffer } { + const privKey = seed ? seed.subarray(0, 32) : crypto.randomBytes(32); + const pubKey = Buffer.from(x25519.getPublicKey(privKey)); + return { privKey: Buffer.from(privKey), pubKey }; +} + +/** + * Per-party deterministic seed material. To use the same seed for both, pass it as both fields. + * `encKey` seeds the X25519 encryption key; `dkgSeed` seeds DKG round 0. + */ +export interface EdDsaDKGPartySeed { + encKey?: Buffer; + dkgSeed?: Buffer; +} + +function validateSeed(seed?: EdDsaDKGPartySeed): EdDsaDKGPartySeed { + assert(!seed?.encKey || seed.encKey.length >= 32, 'encKey must be at least 32 bytes'); + assert(!seed?.dkgSeed || seed.dkgSeed.length >= 32, 'dkgSeed must be at least 32 bytes'); + return seed ?? {}; +} + +/** See `EdDsaDKGPartySeed`. Mirrors `DklsUtils.generateDKGKeyShares` for ECDSA DKLS. */ +export async function generateEdDsaDKGKeyShares( + seedUser?: EdDsaDKGPartySeed, + seedBackup?: EdDsaDKGPartySeed, + seedBitgo?: EdDsaDKGPartySeed +): Promise<[DKG, DKG, DKG]> { + const { encKey: userEncKey, dkgSeed: userDkgSeed } = validateSeed(seedUser); + const { encKey: backupEncKey, dkgSeed: backupDkgSeed } = validateSeed(seedBackup); + const { encKey: bitgoEncKey, dkgSeed: bitgoDkgSeed } = validateSeed(seedBitgo); + + const user = new DKG(3, 2, 0); + const backup = new DKG(3, 2, 1); + const bitgo = new DKG(3, 2, 2); + + const userKP = generateX25519Keypair(userEncKey); + const backupKP = generateX25519Keypair(backupEncKey); + const bitgoKP = generateX25519Keypair(bitgoEncKey); + + await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); + await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); + await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); + + const r1Messages = [ + user.getFirstMessage(userDkgSeed), + backup.getFirstMessage(backupDkgSeed), + bitgo.getFirstMessage(bitgoDkgSeed), + ]; + + const r2Messages = [ + ...user.handleIncomingMessages(r1Messages), + ...backup.handleIncomingMessages(r1Messages), + ...bitgo.handleIncomingMessages(r1Messages), + ]; + + user.handleIncomingMessages(r2Messages); + backup.handleIncomingMessages(r2Messages); + bitgo.handleIncomingMessages(r2Messages); + + return [user, backup, bitgo]; +} diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts index 8c9b98e6eb..1f458298b8 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/dkg.ts @@ -134,8 +134,11 @@ describe('EdDSA MPS DKG', function () { const seedUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); const seedBackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); const seedBitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const userParty = { encKey: seedUser, dkgSeed: seedUser }; + const backupParty = { encKey: seedBackup, dkgSeed: seedBackup }; + const bitgoParty = { encKey: seedBitgo, dkgSeed: seedBitgo }; - const [user1, backup1, bitgo1] = await generateEdDsaDKGKeyShares(seedUser, seedBackup, seedBitgo); + const [user1, backup1, bitgo1] = await generateEdDsaDKGKeyShares(userParty, backupParty, bitgoParty); const pk0 = user1.getSharePublicKey().toString('hex'); const pk1 = backup1.getSharePublicKey().toString('hex'); @@ -143,7 +146,7 @@ describe('EdDSA MPS DKG', function () { assert.strictEqual(pk0, pk1, 'User and backup should have same public key'); assert.strictEqual(pk1, pk2, 'Backup and BitGo should have same public key'); - const [user2] = await generateEdDsaDKGKeyShares(seedUser, seedBackup, seedBitgo); + const [user2] = await generateEdDsaDKGKeyShares(userParty, backupParty, bitgoParty); assert.strictEqual( user1.getSharePublicKey().toString('hex'), user2.getSharePublicKey().toString('hex'), @@ -152,15 +155,22 @@ describe('EdDSA MPS DKG', function () { }); it('should create different key shares with different seeds', async function () { + const seedAUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedABackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedABitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const seedBUser = Buffer.from('b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', 'hex'); + const seedBBackup = Buffer.from('ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a', 'hex'); + const seedBBitgo = Buffer.from('44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', 'hex'); + const [user1] = await generateEdDsaDKGKeyShares( - Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'), - Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'), - Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex') + { encKey: seedAUser, dkgSeed: seedAUser }, + { encKey: seedABackup, dkgSeed: seedABackup }, + { encKey: seedABitgo, dkgSeed: seedABitgo } ); const [user2] = await generateEdDsaDKGKeyShares( - Buffer.from('b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', 'hex'), - Buffer.from('ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a', 'hex'), - Buffer.from('44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', 'hex') + { encKey: seedBUser, dkgSeed: seedBUser }, + { encKey: seedBBackup, dkgSeed: seedBBackup }, + { encKey: seedBBitgo, dkgSeed: seedBBitgo } ); assert.notStrictEqual( diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts index 38fa856290..32689be628 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/eddsa-utils.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import { concatBytes } from '../../../../src/tss/eddsa-mps/util'; +import { concatBytes, generateEdDsaDKGKeyShares } from '../../../../src/tss/eddsa-mps/util'; describe('EdDSA Utility Functions', function () { describe('concatBytes', function () { @@ -14,4 +14,41 @@ describe('EdDSA Utility Functions', function () { assert.deepStrictEqual(result, expected, 'concatBytes should concatenate arrays correctly'); }); }); + + describe('generateEdDsaDKGKeyShares', function () { + const seedUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedBackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedBitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const dkgSeedUser = Buffer.from('b415844d27dd9320f282d6d8ecd8387f0e9fbf9198664e28a2f66e6f5b87c381', 'hex'); + const dkgSeedBackup = Buffer.from('ae02d3f7464313d0f72f9f3862694579fa11f8983fc3fe42183cd137e3f3f30a', 'hex'); + const dkgSeedBitgo = Buffer.from('44d85ab746decb8f0f0c62be0498542ddf58f31d9ed24bd1f62b1b1be17fce0f', 'hex'); + + it('should be deterministic with split encKey and dkgSeed', async function () { + const split = { + user: { encKey: seedUser, dkgSeed: dkgSeedUser }, + backup: { encKey: seedBackup, dkgSeed: dkgSeedBackup }, + bitgo: { encKey: seedBitgo, dkgSeed: dkgSeedBitgo }, + }; + const [user, backup, bitgo] = await generateEdDsaDKGKeyShares(split.user, split.backup, split.bitgo); + const [repeatUser] = await generateEdDsaDKGKeyShares(split.user, split.backup, split.bitgo); + + const userPublicKey = user.getSharePublicKey().toString('hex'); + assert.strictEqual(userPublicKey, backup.getSharePublicKey().toString('hex')); + assert.strictEqual(userPublicKey, bitgo.getSharePublicKey().toString('hex')); + assert.strictEqual(userPublicKey, repeatUser.getSharePublicKey().toString('hex')); + }); + + it('should reject seeds shorter than 32 bytes', async function () { + const okBackup = { encKey: seedBackup, dkgSeed: dkgSeedBackup }; + const okBitgo = { encKey: seedBitgo, dkgSeed: dkgSeedBitgo }; + await assert.rejects( + generateEdDsaDKGKeyShares({ encKey: Buffer.alloc(31), dkgSeed: dkgSeedUser }, okBackup, okBitgo), + /encKey must be at least 32 bytes/ + ); + await assert.rejects( + generateEdDsaDKGKeyShares({ encKey: seedUser, dkgSeed: Buffer.alloc(31) }, okBackup, okBitgo), + /dkgSeed must be at least 32 bytes/ + ); + }); + }); }); diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts index 3fc091bd6e..0005208e85 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/util.ts @@ -1,60 +1,9 @@ -import crypto from 'crypto'; -import { x25519 } from '@noble/curves/ed25519'; -import { EddsaMPSDkg, EddsaMPSDsg } from '../../../../src/tss/eddsa-mps'; +import { EddsaMPSDsg } from '../../../../src/tss/eddsa-mps'; import { DeserializedMessage } from '../../../../src/tss/eddsa-mps/types'; -/** - * Generates an X25519 keypair. If a seed is provided (32 bytes), it is used as the - * private key directly, giving deterministic output. This mirrors how the orchestrator - * extracts X25519 keys from GPG encryption subkeys. - */ -function generateX25519Keypair(seed?: Buffer): { privKey: Buffer; pubKey: Buffer } { - const privKey = seed ? seed.subarray(0, 32) : crypto.randomBytes(32); - const pubKey = Buffer.from(x25519.getPublicKey(privKey)); - return { privKey: Buffer.from(privKey), pubKey }; -} - -/** - * Generates EdDSA DKG key shares for 3 parties with optional seeds. - * Seeds are used as X25519 private keys AND as DKG round0 seeds for full determinism. - */ -export async function generateEdDsaDKGKeyShares( - seedUser?: Buffer, - seedBackup?: Buffer, - seedBitgo?: Buffer -): Promise<[EddsaMPSDkg.DKG, EddsaMPSDkg.DKG, EddsaMPSDkg.DKG]> { - const user = new EddsaMPSDkg.DKG(3, 2, 0); - const backup = new EddsaMPSDkg.DKG(3, 2, 1); - const bitgo = new EddsaMPSDkg.DKG(3, 2, 2); - - const userKP = generateX25519Keypair(seedUser); - const backupKP = generateX25519Keypair(seedBackup); - const bitgoKP = generateX25519Keypair(seedBitgo); - - // Each party gets own privKey + other parties' pubKeys sorted by ascending party index - await user.initDkg(userKP.privKey, [backupKP.pubKey, bitgoKP.pubKey]); - await backup.initDkg(backupKP.privKey, [userKP.pubKey, bitgoKP.pubKey]); - await bitgo.initDkg(bitgoKP.privKey, [userKP.pubKey, backupKP.pubKey]); - - // Use seed as DKG round0 seed for determinism when seed is provided - const r1Messages = [ - user.getFirstMessage(seedUser), - backup.getFirstMessage(seedBackup), - bitgo.getFirstMessage(seedBitgo), - ]; - - const r2Messages = [ - ...user.handleIncomingMessages(r1Messages), - ...backup.handleIncomingMessages(r1Messages), - ...bitgo.handleIncomingMessages(r1Messages), - ]; - - user.handleIncomingMessages(r2Messages); - backup.handleIncomingMessages(r2Messages); - bitgo.handleIncomingMessages(r2Messages); - - return [user, backup, bitgo]; -} +// Re-export the production helper so existing tests can resolve via './util' +// without a separate, drifting copy. +export { generateEdDsaDKGKeyShares } from '../../../../src/tss/eddsa-mps/util'; /** * Runs a full 2-of-3 EdDSA DSG protocol between two parties holding `keyShareA`