From eed96685e5f5f46926e4271e1b31a37b7ce438f0 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Thu, 4 Jun 2026 15:50:53 +0530 Subject: [PATCH] test(express): add EdDSA MPCv2 external-signer DSG round-trip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: WCI-634 Adds a full 3-round DSG round-trip test for the EdDSA MPCv2 external-signer path to externalSign.ts, filling the last gap in the external-signer test matrix (legacy EdDSA v1, ECDSA MPCv2, and EdDSA MPCv2 are now all covered). - Generates real DKG key shares via MPSUtil.generateEdDsaDKGKeyShares() - Drives handleV2GenerateShareTSS for Round1→2→3 with real WASM sessions - Adds signBitgoEddsaMPCv2Round1/2/3 helpers (BitGo-side DSG simulator) using EddsaMPSDsg.DSG + MPSComms stubs, mirroring signBitgoMPCv2Round1/2/3 - GPG layer is stubbed (MPSComms.detachSignMpsMessage/verifyMpsMessage) so the test exercises only the WASM DSG protocol without requiring openpgp or @noble/curves as direct express dependencies — consistent with how the legacy EdDSA v1 test uses only SDK-level abstractions - Final assertion verifies the Ed25519 signature cryptographically via Node.js built-in crypto.verify against the BIP-32-derived public key (deriveUnhardenedMps), consistent with MPC.verify in the legacy EdDSA v1 test and DklsUtils.verifyAndConvertDklsSignature in the ECDSA MPCv2 test Co-Authored-By: Claude Sonnet 4.6 --- .../test/unit/clientRoutes/externalSign.ts | 299 +++++++++++++++++- 1 file changed, 298 insertions(+), 1 deletion(-) diff --git a/modules/express/test/unit/clientRoutes/externalSign.ts b/modules/express/test/unit/clientRoutes/externalSign.ts index b09de2e623..2eb1113264 100644 --- a/modules/express/test/unit/clientRoutes/externalSign.ts +++ b/modules/express/test/unit/clientRoutes/externalSign.ts @@ -14,8 +14,9 @@ import { ShareKeyPosition, TxRequest, SignatureShareRecord, + generateGPGKeyPair, } from '@bitgo/sdk-core'; -import { Hash } from 'crypto'; +import { Hash, createPublicKey, verify as cryptoVerify } from 'crypto'; import { logger } from '@bitgo/logger'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import * as should from 'should'; @@ -28,6 +29,11 @@ import { DklsTypes, DklsComms, DklsDsg, + EddsaMPSDsg, + MPSUtil, + MPSComms, + MPSTypes, + deriveUnhardenedMps, } from '@bitgo/sdk-lib-mpc'; import { MPCv2PartyFromStringOrNumber, @@ -36,6 +42,11 @@ import { MPCv2SignatureShareRound2Input, MPCv2SignatureShareRound2Output, MPCv2SignatureShareRound3Input, + EddsaMPCv2SignatureShareRound1Input, + EddsaMPCv2SignatureShareRound1Output, + EddsaMPCv2SignatureShareRound2Input, + EddsaMPCv2SignatureShareRound2Output, + EddsaMPCv2SignatureShareRound3Input, } from '@bitgo/public-types'; import * as assert from 'assert'; import nock from 'nock'; @@ -1069,6 +1080,166 @@ describe('External signer', () => { envStub.restore(); }); + it('should read an encrypted prv from signerFileSystemPath and pass it to EddsaMPCv2Round1, EddsaMPCv2Round2 and EddsaMPCv2Round3 share generators', async function () { + // EdDSA MPS DSG flow is CPU-heavy; extend timeout for CI stability + this.timeout(180000); + const walletID = '6a19371071fdd4b35159f3860fc3c4e2'; + // 16-byte hex — EdDSA takes raw message bytes (no prehashing required) + const signableHex = 'deadbeef01020304deadbeef01020304'; + const derivationPath = 'm/0'; + const walletPassphrase = 'testPassEdDSA'; + + const [userDkg, , bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const userKeyShareBuffer = userDkg.getKeyShare(); + const bitgoKeyShareBuffer = bitgoDkg.getKeyShare(); + + const bgTest = new BitGo({ env: 'test' }); + const userKeyShareB64 = userKeyShareBuffer.toString('base64'); + const validPrvEdDSA = bgTest.encrypt({ input: userKeyShareB64, password: walletPassphrase }); + const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(JSON.stringify({ [walletID]: validPrvEdDSA })); + const envStub = sinon.stub(process, 'env').value({ ['WALLET_' + walletID + '_PASSPHRASE']: walletPassphrase }); + + // EdDSA MPCv2 requires ed25519 GPG keys (not secp256k1) for PGP-authenticated message exchange. + // GPG auth is tested by the sdk-core unit tests; here we stub the comms layer so the test + // exercises only the WASM DSG protocol without needing openpgp as a direct express dep. + const bitgoEddsaGpgKey = await generateGPGKeyPair('ed25519'); + const detachSignStub = sinon + .stub(MPSComms, 'detachSignMpsMessage') + .callsFake(async (rawBytes) => ({ message: Buffer.from(rawBytes).toString('base64'), signature: '' })); + const verifyMsgStub = sinon + .stub(MPSComms, 'verifyMpsMessage') + .callsFake(async (msg) => Buffer.from(msg.message, 'base64')); + + // Initialise BitGo-side DSG session (party 2, co-signing with User party 0) + const message = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(2 /* BITGO */); + bitgoDsg.initDsg(bitgoKeyShareBuffer, message, derivationPath, 0 /* USER */); + + const baseTxRequest = { + txRequestId: 'eddsa-mpcv2-round-trip-test', + apiVersion: 'full', + walletId: walletID, + transactions: [ + { + unsignedTx: { derivationPath, signableHex }, + signatureShares: [] as SignatureShareRecord[], + }, + ], + } as unknown as TxRequest; + + // round 1 + const reqEdDSARound1 = { + bitgo: bgTest, + body: { txRequest: baseTxRequest }, + decoded: { coin: 'tsol', sharetype: 'EddsaMPCv2Round1', txRequest: baseTxRequest }, + params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round1' }, + config: { signerFileSystemPath: 'signerFileSystemPath' }, + } as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>; + + const round1Result = await handleV2GenerateShareTSS(reqEdDSARound1); + round1Result.should.have.property('signatureShareRound1'); + round1Result.should.have.property('userGpgPubKey'); + round1Result.should.have.property('encryptedRound1Session'); + round1Result.should.have.property('encryptedUserGpgPrvKey'); + + const { txRequest: txRequestRound1, bitgoMsg2 } = await signBitgoEddsaMPCv2Round1( + bitgoDsg, + baseTxRequest, + round1Result.signatureShareRound1, + round1Result.userGpgPubKey + ); + assert.ok( + txRequestRound1.transactions && + txRequestRound1.transactions.length === 1 && + txRequestRound1.transactions[0].signatureShares.length === 2, + 'txRequestRound1 should have 2 signatureShares (user round1 + bitgo round1Output) after BitGo round 1' + ); + + // round 2 + const reqEdDSARound2 = { + bitgo: bgTest, + body: { + txRequest: txRequestRound1, + encryptedRound1Session: round1Result.encryptedRound1Session, + encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, + bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey, + }, + decoded: { + coin: 'tsol', + sharetype: 'EddsaMPCv2Round2', + txRequest: txRequestRound1, + encryptedRound1Session: round1Result.encryptedRound1Session, + encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, + bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey, + }, + params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round2' }, + config: { signerFileSystemPath: 'signerFileSystemPath' }, + } as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>; + + const round2Result = await handleV2GenerateShareTSS(reqEdDSARound2); + round2Result.should.have.property('signatureShareRound2'); + round2Result.should.have.property('encryptedRound2Session'); + + const { txRequest: txRequestRound2, bitgoMsg3 } = await signBitgoEddsaMPCv2Round2( + bitgoDsg, + txRequestRound1, + round2Result.signatureShareRound2, + round1Result.userGpgPubKey, + bitgoMsg2 + ); + assert.ok( + txRequestRound2.transactions && + txRequestRound2.transactions.length === 1 && + txRequestRound2.transactions[0].signatureShares.length === 4, + 'txRequestRound2 should have 4 signatureShares (2 from round1 + user round2 + bitgo round2Output) after BitGo round 2' + ); + + // round 3 + const reqEdDSARound3 = { + bitgo: bgTest, + body: { + txRequest: txRequestRound2, + encryptedRound2Session: round2Result.encryptedRound2Session, + encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, + bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey, + }, + decoded: { + coin: 'tsol', + sharetype: 'EddsaMPCv2Round3', + txRequest: txRequestRound2, + encryptedRound2Session: round2Result.encryptedRound2Session, + encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey, + bitgoPublicGpgKey: bitgoEddsaGpgKey.publicKey, + }, + params: { coin: 'tsol', sharetype: 'EddsaMPCv2Round3' }, + config: { signerFileSystemPath: 'signerFileSystemPath' }, + } as unknown as ExpressApiRouteRequest<'express.v2.tssshare.generate', 'post'>; + + const round3Result = await handleV2GenerateShareTSS(reqEdDSARound3); + round3Result.should.have.property('signatureShareRound3'); + + await signBitgoEddsaMPCv2Round3(bitgoDsg, round3Result.signatureShareRound3, round1Result.userGpgPubKey, bitgoMsg3); + + // Verify the 64-byte Ed25519 signature cryptographically, matching the pattern of the + // legacy EdDSA v1 test (MPC.verify) and ECDSA MPCv2 test (DklsUtils.verifyAndConvert...). + // Uses Node.js built-in crypto — no extra npm dependency needed. + const signature = bitgoDsg.getSignature(); + const derivedKeychainHex = deriveUnhardenedMps(userDkg.getCommonKeychain(), derivationPath); + const derivedPubKeyBytes = Buffer.from(derivedKeychainHex.slice(0, 64), 'hex'); + // Ed25519 SubjectPublicKeyInfo DER header: SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING } + const spkiDer = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), derivedPubKeyBytes]); + const pubKeyObj = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); + assert.ok( + cryptoVerify(null, message, pubKeyObj, signature), + 'Ed25519 signature must verify under the derived public key' + ); + + detachSignStub.restore(); + verifyMsgStub.restore(); + readFileStub.restore(); + envStub.restore(); + }); + it('should accept a local secret and password for a wallet', async () => { const accessToken = ''; const walletIds = { @@ -1154,6 +1325,132 @@ function createExternalSignerTxRequest(txRequestId: string): TxRequest { }; } +// #region EdDSA MPCv2 utils +/** + * Simulates BitGo's server-side processing for EdDSA MPCv2 DSG round 1. + * + * BitGo receives User's round-1 share (WASM-round-0 commitment), runs its own + * WASM-round-0 (getFirstMessage) and WASM-round-1 (handleIncomingMessages), then + * responds with its own WASM-round-0 output (msg1) signed with its GPG key. + * + * Returns the updated txRequest (with both User and BitGo round-1 shares appended) + * and `bitgoMsg2` which must be passed to the round-2 simulator. + */ +async function signBitgoEddsaMPCv2Round1( + bitgoDsg: EddsaMPSDsg.DSG, + txRequest: TxRequest, + userShare: SignatureShareRecord, + userGpgPubKeyArmored: string +): Promise<{ txRequest: TxRequest; bitgoMsg2: MPSTypes.DeserializedMessage }> { + assert.ok( + txRequest.transactions && txRequest.transactions.length === 1, + 'txRequest.transactions must be an array of length 1' + ); + + // User's share must be present so createOfflineRound2Share can find it later + txRequest.transactions[0].signatureShares.push(userShare); + + // MPSComms is stubbed for this test — decode the payload directly from the base64 message field + const parsedUserShare = JSON.parse(userShare.share) as EddsaMPCv2SignatureShareRound1Input; + const userMsg1: MPSTypes.DeserializedMessage = { + from: 0 /* USER */, + payload: new Uint8Array(Buffer.from(parsedUserShare.data.msg1.message, 'base64')), + }; + + // BitGo WASM-round-0: produce its own commitment + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + // BitGo WASM-round-1: process User's msg1 → produce BitGo's msg2 (carried to round 2) + const [bitgoMsg2] = bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]); + + // Build Round1Output using the same shape MPSComms.detachSignMpsMessage stub returns + const bitgoRound1Output: EddsaMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { msg1: { message: Buffer.from(bitgoMsg1.payload).toString('base64'), signature: '' } }, + }; + txRequest.transactions[0].signatureShares.push({ + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify(bitgoRound1Output), + }); + + return { txRequest, bitgoMsg2 }; +} + +/** + * Simulates BitGo's server-side processing for EdDSA MPCv2 DSG round 2. + * + * BitGo receives User's round-2 share (WASM-round-1 response to BitGo's msg1), + * runs its own WASM-round-2 (handling User's msg2 → producing BitGo's msg3), + * then responds with its own WASM-round-1 output (msg2) signed with its GPG key. + * + * Note: BitGo defers sending msg2 until it has received User's msg2 — this is the + * EdDSA-specific protocol asymmetry vs ECDSA (which bundles round1+round2 together). + */ +async function signBitgoEddsaMPCv2Round2( + bitgoDsg: EddsaMPSDsg.DSG, + txRequest: TxRequest, + userShare: SignatureShareRecord, + userGpgPubKeyArmored: string, + bitgoMsg2: MPSTypes.DeserializedMessage +): Promise<{ txRequest: TxRequest; bitgoMsg3: MPSTypes.DeserializedMessage }> { + assert.ok( + txRequest.transactions && txRequest.transactions.length === 1, + 'txRequest.transactions must be an array of length 1' + ); + + txRequest.transactions[0].signatureShares.push(userShare); + + // MPSComms is stubbed — decode payload directly + const parsedUserShare = JSON.parse(userShare.share) as EddsaMPCv2SignatureShareRound2Input; + const userMsg2: MPSTypes.DeserializedMessage = { + from: 0 /* USER */, + payload: new Uint8Array(Buffer.from(parsedUserShare.data.msg2.message, 'base64')), + }; + + // BitGo WASM-round-2: process User's msg2 → produce BitGo's msg3 (carried to round 3) + const [bitgoMsg3] = bitgoDsg.handleIncomingMessages([bitgoMsg2, userMsg2]); + + // Build Round2Output: BitGo sends msg2 (its WASM-round-1 output) back to User + const bitgoRound2Output: EddsaMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { msg2: { message: Buffer.from(bitgoMsg2.payload).toString('base64'), signature: '' } }, + }; + txRequest.transactions[0].signatureShares.push({ + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify(bitgoRound2Output), + }); + + return { txRequest, bitgoMsg3 }; +} + +/** + * Simulates BitGo's server-side processing for EdDSA MPCv2 DSG round 3. + * + * BitGo receives User's round-3 share (WASM-round-2 partial signature contribution), + * runs its own WASM-round-3 to complete the protocol. + * After this call, `bitgoDsg.getSignature()` returns the 64-byte Ed25519 signature. + */ +async function signBitgoEddsaMPCv2Round3( + bitgoDsg: EddsaMPSDsg.DSG, + userShare: SignatureShareRecord, + userGpgPubKeyArmored: string, + bitgoMsg3: MPSTypes.DeserializedMessage +): Promise { + // MPSComms is stubbed — decode payload directly + const parsedUserShare = JSON.parse(userShare.share) as EddsaMPCv2SignatureShareRound3Input; + const userMsg3: MPSTypes.DeserializedMessage = { + from: 0 /* USER */, + payload: new Uint8Array(Buffer.from(parsedUserShare.data.msg3.message, 'base64')), + }; + + // BitGo WASM-round-3: process User's msg3 → complete DSG + bitgoDsg.handleIncomingMessages([bitgoMsg3, userMsg3]); + // bitgoDsg.getSignature() is now available (DsgState.Complete) +} +// #endregion EdDSA MPCv2 utils + // #region MPCv2 utils function getBitGoPartyGpgKeyPrv(bitgoPrvKey: string): DklsTypes.PartyGpgKey { return {