From 54025760545fcebdb40c9dbd403df5b3a7605e6b Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Fri, 22 May 2026 15:58:30 +0530 Subject: [PATCH] feat(sdk-core): add externalSigner createOfflineRound2Share handler Add the EdDSA MPCv2 offline round-2 handler for external signer flows. It restores the encrypted round-1 DSG session and produces the round-2 share plus encrypted carry-over state for round 3. - Restore user DSG session from encrypted round-1 state and verify BitGo round-1 output from txRequest signature shares. - Run WASM round 1 and build the round-2 signature share with user GPG. - Encrypt round-2 session state with MPS_DSG_SIGNING_ROUND2_STATE adata. - Support SJCL and v2 envelope decryption paths for round-1 session input. - Add unit tests with signBitgoEddsaRound1 helper, happy path, and guards. Ticket: WCI-477 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 108 +++++ .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 400 ++++++++++++++++++ 2 files changed, 508 insertions(+) diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 9c11080d1c..2c9f224d21 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -46,6 +46,7 @@ import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2K export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE'; + private static readonly MPS_DSG_SIGNING_ROUND2_STATE = 'MPS_DSG_SIGNING_ROUND2_STATE'; /** @inheritdoc */ async createKeychains(params: { @@ -534,6 +535,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { // #region external signer + // #region Round1Share async createOfflineRound1Share(params: { txRequest: TxRequest; prv: string; @@ -597,6 +599,112 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { return { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey }; } + // #endregion + + // #region Round2Share + async createOfflineRound2Share(params: { + txRequest: TxRequest; + walletPassphrase: string; + bitgoPublicGpgKey: string; + encryptedUserGpgPrvKey: string; + encryptedRound1Session: string; + }): Promise<{ + signatureShareRound2: SignatureShareRecord; + encryptedRound2Session: string; + }> { + const { walletPassphrase, encryptedUserGpgPrvKey, encryptedRound1Session, bitgoPublicGpgKey, txRequest } = params; + + const { signableHex, derivationPath } = this.getSignableHexAndDerivationPath( + txRequest, + 'Unable to find transactions in txRequest' + ); + const adata = `${signableHex}:${derivationPath}`; + + const useV2 = isV2Envelope(encryptedRound1Session); + + const { bitgoGpgKey, userGpgPrvKey } = await this.getBitgoAndUserGpgKeys( + bitgoPublicGpgKey, + encryptedUserGpgPrvKey, + walletPassphrase, + adata, + EddsaMPCv2Utils.MPS_DSG_SIGNING_USER_GPG_KEY + ); + + assert(txRequest.transactions, 'Unable to find transactions in txRequest'); + assert(txRequest.transactions.length === 1, 'txRequest must have exactly one transaction'); + const signatureShares = txRequest.transactions[0].signatureShares; + assert(signatureShares, 'Missing signature shares in round 1 txRequest'); + + const bitgoShareRoundOne = getBitgoSignatureShare(signatureShares, SignatureShareType.USER); + const parsedBitGoToUserSigShareRoundOne = decodeWithCodec( + EddsaMPCv2SignatureShareRound1Output, + JSON.parse(bitgoShareRoundOne.share), + 'Unexpected signature share response. Unable to parse data.' + ); + + if (parsedBitGoToUserSigShareRoundOne.type !== 'round1Output') { + throw new Error('Unexpected signature share response. Unable to parse data.'); + } + + const bitgoDeserializedMsg1 = await verifyPeerMessageRoundOne(parsedBitGoToUserSigShareRoundOne, bitgoGpgKey); + + let decryptedRound1Session: string; + if (useV2) { + decryptedRound1Session = await this.bitgo.decryptAsync({ + input: encryptedRound1Session, + password: walletPassphrase, + }); + } else { + decryptedRound1Session = this.bitgo.decrypt({ + input: encryptedRound1Session, + password: walletPassphrase, + }); + } + this.validateAdata(adata, encryptedRound1Session, EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND1_STATE); + + const { dsgSession, userMsgPayload } = JSON.parse(decryptedRound1Session) as { + dsgSession: string; + userMsgPayload: string; + }; + + const userDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.USER); + userDsg.restoreSession(dsgSession); + const userMsg1: MPSTypes.DeserializedMessage = { + from: MPCv2PartiesEnum.USER, + payload: new Uint8Array(Buffer.from(userMsgPayload, 'base64')), + }; + + const [userMsg2] = userDsg.handleIncomingMessages([userMsg1, bitgoDeserializedMsg1]); + assert(userMsg2, 'WASM round 1 produced no message'); + + const signatureShareRound2 = await getSignatureShareRoundTwo(userMsg2, userGpgPrvKey); + const sessionPayload = JSON.stringify({ + dsgSession: userDsg.getSession(), + userMsgPayload: Buffer.from(userMsg2.payload).toString('base64'), + }); + + if (useV2) { + const session = await this.bitgo.createEncryptionSession(walletPassphrase); + try { + const encryptedRound2Session = await session.encrypt( + sessionPayload, + `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE}:${adata}` + ); + return { signatureShareRound2, encryptedRound2Session }; + } finally { + session.destroy(); + } + } + + const encryptedRound2Session = this.bitgo.encrypt({ + input: sessionPayload, + password: walletPassphrase, + adata: `${EddsaMPCv2Utils.MPS_DSG_SIGNING_ROUND2_STATE}:${adata}`, + }); + + return { signatureShareRound2, encryptedRound2Session }; + } + // #endregion /** @inheritdoc */ async signEddsaMPCv2TssUsingExternalSigner( 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 index 4eb1958e2b..234e7d156f 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -484,6 +484,406 @@ describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { }); }); +describe('EddsaMPCv2Utils.createOfflineRound2Share', () => { + let eddsaMPCv2Utils: EddsaMPCv2Utils; + let mockBitgo: BitGoBase; + let userKeyShare: Buffer; + let bitgoKeyShare: Buffer; + let bitgoGpgKeyPair: pgp.SerializedKeyPair; + let bitgoGpgPrivKey: pgp.PrivateKey; + + const walletPassphrase = 'testPass'; + const signableHex = 'deadbeef'; + const derivationPath = 'm/0/0'; + const expectedAdata = `${signableHex}:${derivationPath}`; + + const baseTxRequest: TxRequest = { + txRequestId: 'txreq-eddsa-round2', + walletId: 'wallet-eddsa-round2', + enterpriseId: 'enterprise-eddsa-round2', + apiVersion: 'full', + transactions: [ + { + unsignedTx: { + signableHex, + derivationPath, + serializedTxHex: signableHex, + }, + signatureShares: [], + }, + ], + intent: { intentType: 'payment' }, + unsignedTxs: [], + } as unknown as TxRequest; + + before('generate EdDSA key shares and GPG keys', async () => { + const [userDkg, , bitgoDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + userKeyShare = userDkg.getKeyShare(); + bitgoKeyShare = bitgoDkg.getKeyShare(); + + bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + }); + + beforeEach(() => { + mockBitgo = { + encrypt: sinon.stub().callsFake((params) => { + const salt = randomBytes(8); + const iv = randomBytes(16); + return sjcl.encrypt(params.password, params.input, { + salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))], + iv: [ + bytesToWord(iv.subarray(0, 4)), + bytesToWord(iv.subarray(4, 8)), + bytesToWord(iv.subarray(8, 12)), + bytesToWord(iv.subarray(12, 16)), + ], + adata: params.adata, + }); + }), + decrypt: sinon.stub().callsFake((params) => sjcl.decrypt(params.password, params.input)), + } as unknown as BitGoBase; + + const mockCoin = { + getMPCAlgorithm: sinon.stub().returns('eddsa'), + } as unknown as IBaseCoin; + + eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin); + }); + + it('should create a round-2 share from offline round 1', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + const round2 = await eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }); + + assert.strictEqual(round2.signatureShareRound2.from, SignatureShareType.USER); + assert.strictEqual(round2.signatureShareRound2.to, SignatureShareType.BITGO); + + const parsedShare = decodeWithCodec( + EddsaMPCv2SignatureShareRound2Input, + JSON.parse(round2.signatureShareRound2.share), + 'EddsaMPCv2SignatureShareRound2Input' + ); + assert.strictEqual(parsedShare.type, 'round2Input'); + assert.ok(parsedShare.data.msg2.message, 'msg2.message should be set'); + assert.ok(parsedShare.data.msg2.signature, 'msg2.signature should be set'); + + const encryptedRound2Session = JSON.parse(round2.encryptedRound2Session); + assert.strictEqual( + decodeURIComponent(encryptedRound2Session.adata), + `MPS_DSG_SIGNING_ROUND2_STATE:${expectedAdata}` + ); + + const sessionPayload = JSON.parse(sjcl.decrypt(walletPassphrase, round2.encryptedRound2Session)); + assert.ok(sessionPayload.dsgSession, 'dsgSession should be persisted for round 3'); + assert.ok(sessionPayload.userMsgPayload, 'userMsgPayload should be persisted for round 3'); + }); + + it('should use v2 decryption when encryptedRound1Session is a v2 envelope', async () => { + const encrypt = sinon + .stub() + .callsFake((input: string, adata: string) => Promise.resolve(JSON.stringify({ v: 2, input, adata }))); + const destroy = sinon.stub(); + const createEncryptionSession = sinon.stub().resolves({ encrypt, destroy }); + mockBitgo.createEncryptionSession = createEncryptionSession; + + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + encryptedPrv: JSON.stringify({ v: 2 }), + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + const decryptAsync = sinon.stub().callsFake(async (params: { input: string }) => { + const envelope = JSON.parse(params.input); + return envelope.input; + }); + mockBitgo.decryptAsync = decryptAsync; + + const round2 = await eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }); + + sinon.assert.called(mockBitgo.decryptAsync as sinon.SinonStub); + sinon.assert.notCalled(mockBitgo.decrypt as sinon.SinonStub); + assert.strictEqual(round2.signatureShareRound2.from, SignatureShareType.USER); + + const encryptedRound2Session = JSON.parse(round2.encryptedRound2Session); + assert.strictEqual(encryptedRound2Session.v, 2); + assert.strictEqual(encryptedRound2Session.adata, `MPS_DSG_SIGNING_ROUND2_STATE:${expectedAdata}`); + }); + + it('should reject tampered encryptedRound1Session adata', async () => { + const otherTxRequest: TxRequest = { + ...baseTxRequest, + transactions: [ + { + unsignedTx: { + signableHex: 'cafebabe', + derivationPath: 'm/1/2', + serializedTxHex: 'cafebabe', + }, + signatureShares: [], + }, + ], + } as unknown as TxRequest; + + const round1Other = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: otherTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1Other.encryptedRound1Session, + }), + /Adata does not match cyphertext adata/ + ); + }); + + it('should reject tampered encryptedUserGpgPrvKey adata', async () => { + const otherTxRequest: TxRequest = { + ...baseTxRequest, + transactions: [ + { + unsignedTx: { + signableHex: 'cafebabe', + derivationPath: 'm/1/2', + serializedTxHex: 'cafebabe', + }, + signatureShares: [], + }, + ], + } as unknown as TxRequest; + + const round1Other = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: otherTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const messageBuffer = Buffer.from(signableHex, 'hex'); + const bitgoDsg = new EddsaMPSDsg.DSG(MPCv2PartiesEnum.BITGO); + bitgoDsg.initDsg(bitgoKeyShare, messageBuffer, derivationPath, MPCv2PartiesEnum.USER); + + const txRequestRound1 = await signBitgoEddsaRound1( + bitgoDsg, + cloneTxRequestWithEmptySignatureShares(baseTxRequest), + round1.signatureShareRound1, + round1.userGpgPubKey, + bitgoGpgPrivKey + ); + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestRound1, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1Other.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }), + /Adata does not match cyphertext adata/ + ); + }); + + it('should reject when BitGo signature share is missing', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + const baseTransaction = assertSingleTransaction(baseTxRequest); + const txRequestNoBitgoShare: TxRequest = { + ...baseTxRequest, + transactions: [ + { + ...baseTransaction, + signatureShares: [round1.signatureShareRound1], + }, + ], + }; + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: txRequestNoBitgoShare, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }), + /Missing BitGo signature share/ + ); + }); + + it('should propagate the tx-only guard when transactions are missing', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: { ...baseTxRequest, transactions: undefined } as unknown as TxRequest, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }), + /Unable to find transactions in txRequest/ + ); + }); + + it('should reject when transactions array is empty', async () => { + const round1 = await eddsaMPCv2Utils.createOfflineRound1Share({ + txRequest: baseTxRequest, + prv: userKeyShare.toString('base64'), + walletPassphrase, + }); + + await assert.rejects( + () => + eddsaMPCv2Utils.createOfflineRound2Share({ + txRequest: { ...baseTxRequest, transactions: [] }, + walletPassphrase, + bitgoPublicGpgKey: bitgoGpgKeyPair.publicKey, + encryptedUserGpgPrvKey: round1.encryptedUserGpgPrvKey, + encryptedRound1Session: round1.encryptedRound1Session, + }), + /Unable to find transactions in txRequest/ + ); + }); +}); + +type TxRequestTransaction = NonNullable[number]; + +function assertSingleTransaction(txRequest: TxRequest): TxRequestTransaction { + assert.ok(txRequest.transactions, 'txRequest must include transactions'); + assert.strictEqual(txRequest.transactions.length, 1, 'txRequest must have exactly one transaction'); + return txRequest.transactions[0]; +} + +function cloneTxRequestWithEmptySignatureShares(txRequest: TxRequest): TxRequest { + const transaction = assertSingleTransaction(txRequest); + return { + ...txRequest, + transactions: [{ ...transaction, signatureShares: [] }], + }; +} + +async function signBitgoEddsaRound1( + bitgoDsg: EddsaMPSDsg.DSG, + txRequest: TxRequest, + userRound1Share: SignatureShareRecord, + userGpgPubKeyArmored: string, + bitgoGpgPrivKey: pgp.PrivateKey +): Promise { + const transaction = assertSingleTransaction(txRequest); + transaction.signatureShares.push(userRound1Share); + + const bitgoMsg1 = bitgoDsg.getFirstMessage(); + + const parsedUserShare = decodeWithCodec( + EddsaMPCv2SignatureShareRound1Input, + JSON.parse(userRound1Share.share), + 'EddsaMPCv2SignatureShareRound1Input' + ); + const userGpgKey = await pgp.readKey({ armoredKey: userGpgPubKeyArmored }); + const userRawMsg1Bytes = await MPSComms.verifyMpsMessage(parsedUserShare.data.msg1, userGpgKey); + const userMsg1 = { + from: MPCv2PartiesEnum.USER, + payload: new Uint8Array(userRawMsg1Bytes), + }; + + bitgoDsg.handleIncomingMessages([bitgoMsg1, userMsg1]); + + const bitgoSignedMsg1 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg1.payload), bitgoGpgPrivKey); + const round1Output: EddsaMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { msg1: bitgoSignedMsg1 }, + }; + + transaction.signatureShares.push({ + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify(round1Output), + }); + + return txRequest; +} + function bytesToWord(bytes?: Uint8Array | number[]): number { if (!(bytes instanceof Uint8Array) || bytes.length !== 4) { throw new Error('bytes must be a Uint8Array with length 4');