diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/common.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/common.ts index 1137d77398a..adf2f60c9dd 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/common.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/common.ts @@ -21,16 +21,27 @@ export async function nockSendSignatureShare( tssType?: 'eddsa' | 'ecdsa'; requestType?: RequestType; apiMode?: 'lite' | 'full'; + requestBodies?: Record[]; }, status = 200 ): Promise { params.requestType = params.requestType || RequestType.tx; params.apiMode = params.apiMode || 'lite'; const transactions = getRoute(params.tssType, params.requestType, params.apiMode); - return nock('https://bitgo.fakeurl') - .persist(true) - .post(`/api/v2/wallet/${params.walletId}/txrequests/${params.txRequestId + transactions}/signatureshares`) - .reply(status, status === 200 ? (params.signatureShare as nock.Body) : ({ error: 'some error' } as nock.Body)); + const scope = nock('https://bitgo.fakeurl'); + if (!params.requestBodies) { + scope.persist(true); + } + const signatureShareRequest = scope.post( + `/api/v2/wallet/${params.walletId}/txrequests/${params.txRequestId + transactions}/signatureshares` + ); + if (params.requestBodies) { + signatureShareRequest.times(params.tssType === 'ecdsa' ? 3 : 2); + } + return signatureShareRequest.reply(status, (_uri, body) => { + params.requestBodies?.push(body as Record); + return status === 200 ? (params.signatureShare as nock.Body) : ({ error: 'some error' } as nock.Body); + }); } export async function nockSendSignatureShareWithResponse( @@ -40,13 +51,17 @@ export async function nockSendSignatureShareWithResponse( signatureShare: unknown; response: unknown; tssType?: 'eddsa' | 'ecdsa'; + requestBodies?: Record[]; }, status = 200 ): Promise { const transactions = getRoute(params.tssType); return nock('https://bitgo.fakeurl') .post(`/api/v2/wallet/${params.walletId}/txrequests/${params.txRequestId + transactions}/signatureshares`) - .reply(status, status === 200 ? (params.response as Body) : ({ error: 'some error' } as nock.Body)); + .reply(status, (_uri, body) => { + params.requestBodies?.push(body as Record); + return status === 200 ? (params.response as Body) : ({ error: 'some error' } as nock.Body); + }); } export async function nockDeleteSignatureShare( diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsa.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsa.ts index b0ac951a8e4..a5c5b36e8b2 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsa.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsa.ts @@ -28,7 +28,9 @@ import { Keychain, RequestTracer, SignatureShareRecord, + ShareKeyPosition, SignatureShareType, + TssTxRecipientSource, TxRequest, Wallet, } from '@bitgo/sdk-core'; @@ -597,8 +599,16 @@ describe('TSS Ecdsa Utils:', async function () { userId: 'userId', }; let aShare, dShare, wShare, oShare, userSignShare, bitgoChallenges, enterpriseChallenges; + const attestation = { + signature: 'test-signature', + credentialId: 'test-credential', + clientDataJSON: 'test-client-data', + authenticatorData: 'test-authenticator-data', + }; + let requestBodies: Record[] = []; beforeEach(async () => { + requestBodies = []; // Initializing user and bitgo for creating shares for nocks const userSigningKey = MPC.keyCombine(userKeyShare.pShare, [bitgoKeyShare.nShares[1], backupKeyShare.nShares[1]]); const bitgoSigningKey = MPC.keyCombine(bitgoKeyShare.pShare, [ @@ -681,6 +691,7 @@ describe('TSS Ecdsa Utils:', async function () { signatureShare: signatureShareOneFromUser, response: signatureShareOneFromBitgo, tssType: 'ecdsa', + requestBodies, }); /** END STEP ONE */ @@ -725,6 +736,7 @@ describe('TSS Ecdsa Utils:', async function () { signatureShare: signatureShareTwoFromUser, response: signatureShareTwoFromBitgo, tssType: 'ecdsa', + requestBodies, }); /** END STEP TWO */ @@ -779,6 +791,7 @@ describe('TSS Ecdsa Utils:', async function () { signatureShare: signatureShareThreeFromUser, response: signatureShareThreeFromBitgo, tssType: 'ecdsa', + requestBodies, }); /* END STEP THREE */ const signature = MPC.constructSignature([userSShare, getBitGoSShare]); @@ -826,6 +839,121 @@ describe('TSS Ecdsa Utils:', async function () { userGpgActual.should.startWith('-----BEGIN PGP PUBLIC KEY BLOCK-----'); }); + it('sends attestation only with the first signature share', async function () { + await setupSignTxRequestNocks(false, userSignShare, aShare, dShare, enterpriseData); + sandbox.stub(baseCoin, 'verifyTransaction').resolves(true); + + await tssUtils.signTxRequest({ + txRequest, + prv: JSON.stringify({ + pShare: userKeyShare.pShare, + bitgoNShare: bitgoKeyShare.nShares[1], + backupNShare: backupKeyShare.nShares[1], + }), + reqId, + recipientSource: TssTxRecipientSource.Explicit, + txParams: { + type: 'transfer', + recipients: [{ address: '0xa1cfb9d51c0af191ff21c5f0f01723e056f7dc12', amount: '1788849860263936' }], + }, + attestation, + }); + + requestBodies.should.have.length(3); + assert.deepStrictEqual(requestBodies[0].attestation, attestation); + requestBodies.slice(1).forEach((body) => body.should.not.have.property('attestation')); + }); + + it('omits attestation from every signature share when absent', async function () { + await setupSignTxRequestNocks(false, userSignShare, aShare, dShare, enterpriseData); + sandbox.stub(baseCoin, 'verifyTransaction').resolves(true); + + await tssUtils.signTxRequest({ + txRequest, + prv: JSON.stringify({ + pShare: userKeyShare.pShare, + bitgoNShare: bitgoKeyShare.nShares[1], + backupNShare: backupKeyShare.nShares[1], + }), + reqId, + recipientSource: TssTxRecipientSource.Explicit, + txParams: { + type: 'transfer', + recipients: [{ address: '0xa1cfb9d51c0af191ff21c5f0f01723e056f7dc12', amount: '1788849860263936' }], + }, + }); + + requestBodies.should.have.length(3); + requestBodies.forEach((body) => body.should.not.have.property('attestation')); + }); + + async function signWithExternalSigner(attestation?: { + signature: string; + credentialId: string; + clientDataJSON: string; + authenticatorData: string; + }) { + await setupSignTxRequestNocks(true, userSignShare, aShare, dShare, enterpriseData); + let externalOShare: ECDSA.OShare | undefined; + sinon.stub(baseCoin, 'verifyTransaction').resolves(true); + + return wallet.signTransaction({ + txRequestId, + ...(attestation ? { attestation } : {}), + customPaillierModulusGeneratingFunction: async () => ({ userPaillierModulus: 'test-paillier-modulus' }), + customKShareGeneratingFunction: async () => ({ + kShare: userSignShare.kShare, + wShare: userSignShare.wShare, + privateShareProof: 'test-private-share-proof', + vssProof: 'test-vss-proof', + userPublicGpgKey: userGpgKey.publicKey, + publicShare: 'test-public-share', + encryptedSignerOffsetShare: 'test-encrypted-offset-share', + }), + customMuDeltaShareGeneratingFunction: async () => { + const userGammaAndMuShares = await ECDSAMethods.createUserGammaAndMuShare(userSignShare.wShare, aShare); + const userOShareAndDShares = MPC.signCombine({ + gShare: userGammaAndMuShares.gShare as ECDSA.GShare, + signIndex: { i: 1, j: 3 }, + }); + const userOShare = userOShareAndDShares.oShare as ECDSA.OShare; + externalOShare = userOShare; + return { + muDShare: { + muShare: userGammaAndMuShares.muShare!, + dShare: userOShareAndDShares.dShare as ECDSA.DShare, + i: ShareKeyPosition.BITGO, + }, + oShare: userOShare, + }; + }, + customSShareGeneratingFunction: async ({ dShareFromBitgo }) => { + assert.ok(externalOShare); + return MPC.sign( + Buffer.from(txRequest.unsignedTxs[0].signableHex, 'hex'), + externalOShare, + dShareFromBitgo, + createKeccakHash('keccak256') as Hash + ); + }, + }); + } + + it('external signer sends attestation only with the first signature share', async function () { + await signWithExternalSigner(attestation); + + requestBodies.should.have.length(3); + assert.deepStrictEqual(requestBodies[0].attestation, attestation); + requestBodies.slice(1).forEach((body) => body.should.not.have.property('attestation')); + }); + + it('external signer omits attestation from every signature share when absent', async function () { + await signWithExternalSigner(); + + requestBodies.should.have.length(3); + requestBodies.forEach((body) => body.should.not.have.property('attestation')); + }); + it('signTxRequest should succeed with txRequest id as input', async function () { const sendShareSpy = sinon.spy(ECDSAMethods, 'sendShareToBitgo' as any); await setupSignTxRequestNocks(true, userSignShare, aShare, dShare, enterpriseData); diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts index 74c1b427b63..0244604c3ed 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts @@ -1,3 +1,5 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; import { Hash } from 'crypto'; import { BaseCoin, @@ -31,6 +33,7 @@ const createKeccakHash = require('keccak'); interface SignatureShareApiBody { signatureShares: SignatureShareRecord[]; signerGpgPublicKey: string; + attestation?: unknown; } describe('signTxRequest:', function () { @@ -41,6 +44,12 @@ describe('signTxRequest:', function () { let bitgoGpgKey: openpgp.SerializedKeyPair; const coinName = 'hteth'; + const attestation = { + signature: 'test-signature', + credentialId: 'test-credential', + clientDataJSON: 'test-client-data', + authenticatorData: 'test-authenticator-data', + }; const reqId = new RequestTracer(); const txRequestId = 'randomTxReqId'; const signableHex = 'e27aecaea559fbedc9ae8a22b0ab6654c2d686403c2aeb434b302545c94eed3b'; @@ -177,6 +186,7 @@ describe('signTxRequest:', function () { afterEach(function () { bitgoParty.endSession(); nock.cleanAll(); + sinon.restore(); }); it('successfully signs a txRequest with user key for a dkls hot wallet with WP', async function () { @@ -200,6 +210,55 @@ describe('signTxRequest:', function () { nockPromises[2].isDone().should.be.true(); }); + it('sends attestation only with the first MPCv2 signature submission', async function () { + sinon.stub(baseCoin, 'verifyTransaction').resolves(true); + const requestBodies: Record[] = []; + const nockPromises = [ + await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey, 0, requestBodies), + await nockTxRequestResponseSignatureShareRoundTwo(bitgoParty, txRequest, bitgoGpgKey, 0, 0, requestBodies), + await nockTxRequestResponseSignatureShareRoundThree(txRequest, requestBodies), + await nockSendTxRequest(txRequest), + ]; + await Promise.all(nockPromises); + + const userShare = fs.readFileSync(shareFiles[vector.party1]); + await tssUtils.signTxRequest({ + txRequest, + prv: Buffer.from(userShare).toString('base64'), + reqId, + recipientSource: TssTxRecipientSource.Explicit, + txParams: { recipients: [{ address: '0x0000000000000000000000000000000000000001', amount: '1' }] }, + attestation, + }); + requestBodies.should.have.length(3); + assert.deepStrictEqual(requestBodies[0].attestation, attestation); + requestBodies.slice(1).forEach((body) => body.should.not.have.property('attestation')); + }); + + it('omits attestation from every MPCv2 signature submission when absent', async function () { + sinon.stub(baseCoin, 'verifyTransaction').resolves(true); + const requestBodies: Record[] = []; + const nockPromises = [ + await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey, 0, requestBodies), + await nockTxRequestResponseSignatureShareRoundTwo(bitgoParty, txRequest, bitgoGpgKey, 0, 0, requestBodies), + await nockTxRequestResponseSignatureShareRoundThree(txRequest, requestBodies), + await nockSendTxRequest(txRequest), + ]; + await Promise.all(nockPromises); + + const userShare = fs.readFileSync(shareFiles[vector.party1]); + await tssUtils.signTxRequest({ + txRequest, + prv: Buffer.from(userShare).toString('base64'), + reqId, + recipientSource: TssTxRecipientSource.Explicit, + txParams: { recipients: [{ address: '0x0000000000000000000000000000000000000001', amount: '1' }] }, + }); + + requestBodies.should.have.length(3); + requestBodies.forEach((body) => body.should.not.have.property('attestation')); + }); + it('successfully signs when recipientSource is explicit and txParams.recipients is non-empty', async function () { const nockPromises = [ await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey), @@ -465,7 +524,8 @@ async function nockTxRequestResponseSignatureShareRoundOne( bitgoSession: DklsDsg.Dsg, txRequest: TxRequest, bitgoGpgKey: openpgp.SerializedKeyPair, - partyId: 0 | 1 = 0 + partyId: 0 | 1 = 0, + requestBodies?: Record[] ): Promise { const transactions = getRoute('ecdsa'); return nock('https://bitgo.fakeurl') @@ -476,6 +536,7 @@ async function nockTxRequestResponseSignatureShareRoundOne( ) .times(1) .reply(200, async (uri, body: SignatureShareApiBody) => { + requestBodies?.push(body as unknown as Record); // Do the actual signing on BitGo's side based on User's messages const signatureShare = JSON.parse(body.signatureShares[0].share) as MPCv2SignatureShareRound1Input; const deserializedMessages = DklsTypes.deserializeMessages({ @@ -544,7 +605,8 @@ async function nockTxRequestResponseSignatureShareRoundTwo( txRequest: TxRequest, bitgoGpgKey: openpgp.SerializedKeyPair, partyId: 0 | 1 = 0, - rateLimitErrorCount = 0 + rateLimitErrorCount = 0, + requestBodies?: Record[] ): Promise { const transactions = getRoute('ecdsa'); const scope = nock('https://bitgo.fakeurl'); @@ -570,6 +632,7 @@ async function nockTxRequestResponseSignatureShareRoundTwo( ) .times(1) .reply(200, async (uri, body: SignatureShareApiBody) => { + requestBodies?.push(body as unknown as Record); // Do the actual signing on BitGo's side based on User's messages const parsedSignatureShare = JSON.parse(body.signatureShares[0].share) as MPCv2SignatureShareRound2Input; const serializedMessages = await DklsComms.decryptAndVerifyIncomingMessages( @@ -645,7 +708,10 @@ async function nockTxRequestResponseSignatureShareRoundTwo( }); } -async function nockTxRequestResponseSignatureShareRoundThree(txRequest: TxRequest): Promise { +async function nockTxRequestResponseSignatureShareRoundThree( + txRequest: TxRequest, + requestBodies?: Record[] +): Promise { const transactions = getRoute('ecdsa'); return nock('https://bitgo.fakeurl') .post( @@ -654,9 +720,8 @@ async function nockTxRequestResponseSignatureShareRoundThree(txRequest: TxReques (JSON.parse(body.signatureShares[0].share) as MPCv2SignatureShareRound3Input).type === 'round3Input' ) .times(1) - .reply(200, async (uri, body) => { - // Do the actual signing on BitGo's side based on User's messages - + .reply(200, async (_uri, body: SignatureShareApiBody) => { + requestBodies?.push(body as unknown as Record); return { txRequestId: txRequest.txRequestId, }; diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts index a1fa4037240..477cdee3516 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts @@ -733,8 +733,18 @@ describe('TSS Utils:', async function () { version: 1, userId: 'userId', }; + const attestation = { + signature: 'test-signature', + credentialId: 'test-credential', + clientDataJSON: 'test-client-data', + authenticatorData: 'test-authenticator-data', + }; + let requestBodies: Record[] = []; beforeEach(async function () { + requestBodies = []; + txRequest.signatureShares = []; + txRequest.state = 'pendingUserSignature'; const userSignShare = validUserSignShare; const rShare = userSignShare.rShares[3]; const signatureShare: SignatureShareRecord = { @@ -747,6 +757,7 @@ describe('TSS Utils:', async function () { walletId: wallet.id(), txRequestId: txRequest.txRequestId, signatureShare, + requestBodies, }); const signatureShare2: SignatureShareRecord = { @@ -782,6 +793,70 @@ describe('TSS Utils:', async function () { sandbox.verifyAndRestore(); }); + it('sends attestation only with the first signature share', async function () { + await tssUtils.signTxRequest({ + txRequest, + prv: JSON.stringify(validUserSigningMaterial), + reqId, + attestation, + }); + + requestBodies.should.have.length(2); + assert.deepStrictEqual(requestBodies[0].attestation, attestation); + requestBodies[1].should.not.have.property('attestation'); + }); + + async function signWithExternalSigner(attestation?: { + signature: string; + credentialId: string; + clientDataJSON: string; + authenticatorData: string; + }) { + return tssUtils.signEddsaTssUsingExternalSigner( + txRequest, + async () => ({ + userToBitgoCommitment: tssUtils.createUserToBitgoCommitmentShare('user-commitment'), + encryptedSignerShare: tssUtils.createUserToBitgoEncryptedSignerShare('encrypted-signer-share'), + encryptedUserToBitgoRShare: tssUtils.createUserToBitgoEncryptedSignerShare('encrypted-r-share'), + }), + async () => ({ rShare: validUserSignShare }), + async () => ({ + i: 1, + y: 'user-y-share', + gamma: 'user-gamma-share', + R: 'user-R-share', + }), + reqId, + attestation + ); + } + + it('external signer sends attestation only with the first signature share', async function () { + await signWithExternalSigner(attestation); + + requestBodies.should.have.length(2); + assert.deepStrictEqual(requestBodies[0].attestation, attestation); + requestBodies[1].should.not.have.property('attestation'); + }); + + it('external signer omits attestation from every signature share when absent', async function () { + await signWithExternalSigner(); + + requestBodies.should.have.length(2); + requestBodies.forEach((body) => body.should.not.have.property('attestation')); + }); + + it('omits attestation from every signature share when absent', async function () { + await tssUtils.signTxRequest({ + txRequest, + prv: JSON.stringify(validUserSigningMaterial), + reqId, + }); + + requestBodies.should.have.length(2); + requestBodies.forEach((body) => body.should.not.have.property('attestation')); + }); + it('signTxRequest should succeed with txRequest id as input', async function () { const getTxRequest = sandbox.stub(tssUtils, 'getTxRequest'); getTxRequest.resolves(txRequest); diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/signTxRequest.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/signTxRequest.ts index 1e6d03300c4..a1b2b1bc1b7 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/signTxRequest.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/eddsaMPCv2/signTxRequest.ts @@ -1,3 +1,4 @@ +import * as assert from 'assert'; import * as sinon from 'sinon'; import { BaseCoin, @@ -31,6 +32,7 @@ const MPCv2PartiesEnum = ECDSAUtils.MPCv2PartiesEnum; interface SignatureShareApiBody { signatureShares: SignatureShareRecord[]; signerGpgPublicKey: string; + attestation?: unknown; } describe('signTxRequest:', function () { @@ -41,6 +43,12 @@ describe('signTxRequest:', function () { let bitgoGpgKey: openpgp.SerializedKeyPair & { revocationCertificate: string }; let bitgoPrvKeyObj: openpgp.PrivateKey; const coinName = 'sol'; + const attestation = { + signature: 'test-signature', + credentialId: 'test-credential', + clientDataJSON: 'test-client-data', + authenticatorData: 'test-authenticator-data', + }; const reqId = new RequestTracer(); const txRequestId = 'randomTxReqId'; @@ -181,6 +189,56 @@ describe('signTxRequest:', function () { nockPromises[3].isDone().should.be.true(); }); + it('sends attestation only with the first MPCv2 signature submission', async function () { + const requestBodies: Record[] = []; + const nockPromises = await getNockPromisesForEddsaSigning( + txRequest, + RequestType.tx, + 0, + MPCv2PartiesEnum.USER, + 2, + false, + requestBodies + ); + await Promise.all(nockPromises); + + await tssUtils.signTxRequest({ + txRequest, + prv: Buffer.from(userKeyShare).toString('base64'), + reqId, + txParams, + attestation, + }); + + requestBodies.should.have.length(3); + assert.deepStrictEqual(requestBodies[0].attestation, attestation); + requestBodies.slice(1).forEach((body) => body.should.not.have.property('attestation')); + }); + + it('omits attestation from every MPCv2 signature submission when absent', async function () { + const requestBodies: Record[] = []; + const nockPromises = await getNockPromisesForEddsaSigning( + txRequest, + RequestType.tx, + 0, + MPCv2PartiesEnum.USER, + 2, + false, + requestBodies + ); + await Promise.all(nockPromises); + + await tssUtils.signTxRequest({ + txRequest, + prv: Buffer.from(userKeyShare).toString('base64'), + reqId, + txParams, + }); + + requestBodies.should.have.length(3); + requestBodies.forEach((body) => body.should.not.have.property('attestation')); + }); + it('successfully signs a txRequest with backup key for an mps hot wallet with WP', async function () { const nockPromises = await getNockPromisesForEddsaSigning(txRequest, RequestType.tx, 0, MPCv2PartiesEnum.BACKUP); await Promise.all(nockPromises); @@ -554,7 +612,8 @@ describe('signTxRequest:', function () { rateLimitErrorCount = 0, signerPartyId: ECDSAUtils.MPCv2PartiesEnum = MPCv2PartiesEnum.USER, rateLimitRound = 2, - malformedRound3Response = false + malformedRound3Response = false, + requestBodies?: Record[] ): Promise { const txOrMessageToSign = requestType === RequestType.message @@ -582,7 +641,8 @@ describe('signTxRequest:', function () { }, requestType, signerPartyId, - rateLimitRound === 1 ? rateLimitErrorCount : 0 + rateLimitRound === 1 ? rateLimitErrorCount : 0, + requestBodies ), await nockTxRequestResponseSignatureShareRoundTwo( txRequest, @@ -590,14 +650,16 @@ describe('signTxRequest:', function () { bitgoPrvKeyObj, requestType, rateLimitRound === 2 ? rateLimitErrorCount : 0, - signerPartyId + signerPartyId, + requestBodies ), await nockTxRequestResponseSignatureShareRoundThree( txRequest, requestType, signerPartyId, rateLimitRound === 3 ? rateLimitErrorCount : 0, - malformedRound3Response + malformedRound3Response, + requestBodies ), await nockSendTxRequest(txRequest, requestType), ]; @@ -632,7 +694,8 @@ async function nockTxRequestResponseSignatureShareRoundOne( saveBitgoMsg2: (msg: MPSTypes.DeserializedMessage) => void, requestType: RequestType = RequestType.tx, signerPartyId: ECDSAUtils.MPCv2PartiesEnum = MPCv2PartiesEnum.USER, - rateLimitErrorCount = 0 + rateLimitErrorCount = 0, + requestBodies?: Record[] ): Promise { const route = requestType === RequestType.message ? '/messages/0' : '/transactions/0'; const signerShareType = signerPartyId === MPCv2PartiesEnum.USER ? SignatureShareType.USER : SignatureShareType.BACKUP; @@ -661,6 +724,7 @@ async function nockTxRequestResponseSignatureShareRoundOne( (JSON.parse(body.signatureShares[0].share) as EddsaMPCv2SignatureShareRound1Input).type === 'round1Input' ) .reply(200, async (_uri: string, body: SignatureShareApiBody) => { + requestBodies?.push(body as unknown as Record); const parsedShare = JSON.parse(body.signatureShares[0].share) as EddsaMPCv2SignatureShareRound1Input; const userMsg1Bytes = Buffer.from(parsedShare.data.msg1.message, 'base64'); const userDeserializedMsg1: MPSTypes.DeserializedMessage = { @@ -699,7 +763,8 @@ async function nockTxRequestResponseSignatureShareRoundTwo( bitgoGpgPrivKey: openpgp.PrivateKey, requestType: RequestType = RequestType.tx, rateLimitErrorCount = 0, - signerPartyId: ECDSAUtils.MPCv2PartiesEnum = MPCv2PartiesEnum.USER + signerPartyId: ECDSAUtils.MPCv2PartiesEnum = MPCv2PartiesEnum.USER, + requestBodies?: Record[] ): Promise { const route = requestType === RequestType.message ? '/messages/0' : '/transactions/0'; const scope = nock('https://bitgo.fakeurl'); @@ -727,7 +792,8 @@ async function nockTxRequestResponseSignatureShareRoundTwo( (body) => (JSON.parse(body.signatureShares[0].share) as EddsaMPCv2SignatureShareRound2Input).type === 'round2Input' ) - .reply(200, async () => { + .reply(200, async (_uri, body: SignatureShareApiBody) => { + requestBodies?.push(body as unknown as Record); const bitgoMsg2 = getBitgoMsg2(); const bitgoSignedMsg2 = await MPSComms.detachSignMpsMessage(Buffer.from(bitgoMsg2.payload), bitgoGpgPrivKey); const round2Output: EddsaMPCv2SignatureShareRound2Output = { @@ -762,7 +828,8 @@ async function nockTxRequestResponseSignatureShareRoundThree( requestType: RequestType = RequestType.tx, signerPartyId: ECDSAUtils.MPCv2PartiesEnum = MPCv2PartiesEnum.USER, rateLimitErrorCount = 0, - malformedResponse = false + malformedResponse = false, + requestBodies?: Record[] ): Promise { const route = requestType === RequestType.message ? '/messages/0' : '/transactions/0'; const signerShareType = signerPartyId === MPCv2PartiesEnum.USER ? SignatureShareType.USER : SignatureShareType.BACKUP; @@ -790,9 +857,9 @@ async function nockTxRequestResponseSignatureShareRoundThree( (body: SignatureShareApiBody) => (JSON.parse(body.signatureShares[0].share) as EddsaMPCv2SignatureShareRound3Input).type === 'round3Input' ) - .reply( - 200, - malformedResponse + .reply(200, (_uri, body: SignatureShareApiBody) => { + requestBodies?.push(body as unknown as Record); + return malformedResponse ? { txRequestId: `${txRequest.txRequestId}-unexpected` } : { txRequestId: txRequest.txRequestId, @@ -803,8 +870,8 @@ async function nockTxRequestResponseSignatureShareRoundThree( share: 'placeholder', }, ], - } - ); + }; + }); } async function nockSendTxRequest(txRequest: TxRequest, requestType: RequestType = RequestType.tx): Promise { diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 1047b21ee17..065fbe1f109 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -4386,8 +4386,14 @@ describe('V2 Wallet:', function () { capturedRequestBody!.intent.sequenceId.should.equal(params.sequenceId); }); - describe('videoApprovers', function () { + describe('top-level withdrawal request fields', function () { const videoApprovers = ['5cae3130f8f4561d51dfcbfdaafba9b9', '5cae3130f8f4561d51dfcbfdaafba9ba']; + const attestation = { + signature: 'sig', + credentialId: 'credential', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; const adaAddress = 'addr_test1qz4m3ms9nxra36t6rtz4lzc9zkk3d4qwcpcr3yxwhxlslpathrhqtxv8mr5h5xk9t79s29ddzm2qasrs8zgvawdlp7rs44g5hc'; @@ -4405,35 +4411,126 @@ describe('V2 Wallet:', function () { return capturedRequestBody; } - it('should send videoApprovers as a top-level field for transfers', async function () { + it('should send videoApprovers and attestation as top-level fields for transfers', async function () { const body = await captureTxRequestBody({ type: 'transfer', recipients: [{ amount: '2', address: adaAddress }], videoApprovers, + attestation, }); body.videoApprovers.should.deepEqual(videoApprovers); + body.attestation.should.deepEqual(attestation); body.intent.should.not.have.property('videoApprovers'); + body.intent.should.not.have.property('attestation'); }); - it('should send videoApprovers as a top-level field for token transfers', async function () { + it('should send videoApprovers and attestation as top-level fields for token transfers', async function () { const body = await captureTxRequestBody({ type: 'transfertoken', recipients: [{ amount: '2', address: adaAddress, tokenName: 'tada:water' }], videoApprovers, + attestation, }); body.videoApprovers.should.deepEqual(videoApprovers); + body.attestation.should.deepEqual(attestation); body.intent.should.not.have.property('videoApprovers'); - }); + body.intent.should.not.have.property('attestation'); + }); + + it('should omit attestation from signing after creating transfer txRequests', async function () { + const requestBodies: Record[] = []; + const signRequests: Record[] = []; + const recipientsByType = { + transfer: [{ amount: '2', address: adaAddress }], + transfertoken: [{ amount: '2', address: adaAddress, tokenName: 'tada:water' }], + }; + + sandbox + .stub(Keychains.prototype, 'getKeysForSigning') + .resolves([{ commonKeychain: 'test', id: '', pub: '', type: 'independent' }]); + sandbox.stub(tada, 'verifyTransaction').resolves(true); + sandbox.stub(BaseTssUtils.default.prototype, 'getTxRequest').resolves(txRequest); + sandbox.stub(tssAdaWallet, 'getUserPrv').resolves('secretKey'); + sandbox.stub(TssUtils.prototype, 'signTxRequest').callsFake(async (params) => { + signRequests.push(params as unknown as Record); + return txRequest; + }); - it('should not send videoApprovers when none are provided', async function () { - const body = await captureTxRequestBody({ + for (const type of ['transfer', 'transfertoken'] as const) { + const recipients = recipientsByType[type]; + const previewPrebuild = { + walletId: tssAdaWallet.id(), + wallet: tssAdaWallet, + txRequestId: 'preview-id', + txHex: 'preview-tx', + buildParams: { type, recipients, attestation, apiVersion: 'lite', preview: true }, + }; + + for (const prebuildTx of [undefined, previewPrebuild]) { + nock(bgUrl) + .post(`/api/v2/wallet/${tssAdaWallet.id()}/txrequests`, (body) => { + requestBodies.push(body as Record); + return true; + }) + .reply(200, txRequest); + + await tssAdaWallet.prebuildAndSignTransaction({ + walletPassphrase: 'passphrase', + apiVersion: 'lite', + ...(!prebuildTx && { type, recipients, attestation }), + ...(prebuildTx && { prebuildTx }), + }); + } + } + + requestBodies.should.have.length(4); + requestBodies.forEach((body) => assert.deepStrictEqual(body.attestation, attestation)); + assert.strictEqual(requestBodies[1].preview, false); + assert.strictEqual(requestBodies[3].preview, false); + signRequests.should.have.length(4); + signRequests.forEach((params) => params.should.not.have.property('attestation')); + }); + + it('should preserve attestation when signing a supplied prebuildTx', async function () { + const signRequests: Record[] = []; + sandbox + .stub(Keychains.prototype, 'getKeysForSigning') + .resolves([{ commonKeychain: 'test', id: '', pub: '', type: 'independent' }]); + sandbox.stub(tada, 'verifyTransaction').resolves(true); + sandbox.stub(BaseTssUtils.default.prototype, 'getTxRequest').resolves(txRequest); + sandbox.stub(tssAdaWallet, 'getUserPrv').resolves('secretKey'); + sandbox.stub(TssUtils.prototype, 'signTxRequest').callsFake(async (params) => { + signRequests.push(params as unknown as Record); + return txRequest; + }); + + await tssAdaWallet.prebuildAndSignTransaction({ + walletPassphrase: 'passphrase', type: 'transfer', - recipients: [{ amount: '2', address: adaAddress }], + attestation, + prebuildTx: { + walletId: tssAdaWallet.id(), + wallet: tssAdaWallet, + txRequestId: 'existing-id', + txHex: 'existing-tx', + }, }); - body.should.not.have.property('videoApprovers'); + signRequests.should.have.length(1); + assert.deepStrictEqual(signRequests[0].attestation, attestation); + }); + + it('should omit videoApprovers and attestation when absent for transfers', async function () { + for (const type of ['transfer', 'transfertoken'] as const) { + const body = await captureTxRequestBody({ + type, + recipients: [{ amount: '2', address: adaAddress }], + }); + body.should.not.have.property('videoApprovers'); + body.should.not.have.property('attestation'); + } }); }); }); @@ -4442,8 +4539,6 @@ describe('V2 Wallet:', function () { it('should sign transaction', async function () { const signTxRequest = sandbox.stub(TssUtils.prototype, 'signTxRequest'); signTxRequest.resolves(txRequest); - // TODO(BG-59686): this is not doing anything if we don't check the return value, we should also move this check to happen after we invoke signTransaction - signTxRequest.calledOnceWithExactly({ txRequest, prv: 'secretKey', reqId }); const txPrebuild = { walletId: tssSolWallet.id(), @@ -4454,9 +4549,10 @@ describe('V2 Wallet:', function () { const signedTransaction = await tssSolWallet.signTransaction({ reqId, txPrebuild, - prv: 'sercretKey', + prv: 'secretKey', }); signedTransaction.should.deepEqual(txRequest); + signTxRequest.should.have.been.calledOnce(); }); it('should fail to sign transaction without txRequestId', async function () { diff --git a/modules/express/src/typedRoutes/api/v2/sendCoins.ts b/modules/express/src/typedRoutes/api/v2/sendCoins.ts index b59e7b7c318..d00195c704a 100644 --- a/modules/express/src/typedRoutes/api/v2/sendCoins.ts +++ b/modules/express/src/typedRoutes/api/v2/sendCoins.ts @@ -1,5 +1,6 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional, type HttpRoute } from '@api-ts/io-ts-http'; +import { AttestationPayload } from '@bitgo/public-types'; import { BitgoExpressError } from '../../schemas/error'; import { SendManyResponse, EIP1559Params, MemoParams, TokenEnablement } from './sendmany'; @@ -60,6 +61,9 @@ export const SendCoinsRequestBody = { /** Token name, defined in the BitGoJS Statics package. */ tokenName: optional(t.string), + /** WebAuthn proof for the transaction intent (pass-through only) */ + attestation: optional(AttestationPayload), + // All SendManyOptions fields are also supported via the SendOptions index signature // Including these commonly used fields explicitly for better documentation: diff --git a/modules/express/src/typedRoutes/api/v2/sendmany.ts b/modules/express/src/typedRoutes/api/v2/sendmany.ts index 68edb695ebe..bc0ca270aaa 100644 --- a/modules/express/src/typedRoutes/api/v2/sendmany.ts +++ b/modules/express/src/typedRoutes/api/v2/sendmany.ts @@ -1,7 +1,7 @@ import * as t from 'io-ts'; import { DateFromISOString } from 'io-ts-types'; import { httpRoute, httpRequest, optional, type HttpRoute } from '@api-ts/io-ts-http'; -import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types'; +import { AttestationPayload, TransactionRequest as TxRequestResponse } from '@bitgo/public-types'; import { BitgoExpressError } from '../../schemas/error'; /** @@ -145,6 +145,9 @@ export const SendManyRequestBody = { /** The un-encrypted user private key in string form. If the key is a JSON object it must be stringified. Required if `walletPassphrase` is not available or encrypted private key is not stored by BitGo. */ prv: optional(t.string), + /** WebAuthn proof for the transaction intent (pass-through only) */ + attestation: optional(AttestationPayload), + /** * (BTC only) The number of blocks required to confirm a transaction. You can use `numBlocks` to estimate the fee * rate by targeting confirmation within a given number of blocks. If both `feeRate` and `numBlocks` are absent, diff --git a/modules/express/src/typedRoutes/api/v2/walletTxSignTSS.ts b/modules/express/src/typedRoutes/api/v2/walletTxSignTSS.ts index 37d71939a37..78c8984fe86 100644 --- a/modules/express/src/typedRoutes/api/v2/walletTxSignTSS.ts +++ b/modules/express/src/typedRoutes/api/v2/walletTxSignTSS.ts @@ -1,6 +1,10 @@ import * as t from 'io-ts'; import { httpRoute, httpRequest, optional, type HttpRoute } from '@api-ts/io-ts-http'; -import { TransactionRequest as TxRequestResponse, TransactionRequestApiVersion } from '@bitgo/public-types'; +import { + AttestationPayload, + TransactionRequest as TxRequestResponse, + TransactionRequestApiVersion, +} from '@bitgo/public-types'; import { BitgoExpressError } from '../../schemas/error'; import { Recipient } from './coinSignTx'; @@ -84,6 +88,8 @@ export const WalletTxSignTSSBody = { isEvmBasedCrossChainRecovery: optional(t.boolean), /** Derivation seed for key derivation */ derivationSeed: optional(t.string), + /** WebAuthn proof for the transaction intent (pass-through only) */ + attestation: optional(AttestationPayload), } as const; /** diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts index 1ca6ba6f9a1..ca82eceee2e 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -46,6 +46,13 @@ describe('SendCoins V2 codec tests', function () { txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', }; + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; + afterEach(function () { sinon.restore(); }); @@ -55,12 +62,12 @@ describe('SendCoins V2 codec tests', function () { address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', amount: 1000000, walletPassphrase: 'test_passphrase_12345', + attestation, }; // Create mock wallet with send method const mockWallet = { send: sinon.stub().resolves(mockSendResponse), - _wallet: { multisigType: 'onchain' }, }; // Stub the wallets().get() chain @@ -101,6 +108,7 @@ describe('SendCoins V2 codec tests', function () { // Verify that the correct BitGoJS methods were called assert.strictEqual(walletsGetStub.calledOnce, true); assert.strictEqual(mockWallet.send.calledOnce, true); + sinon.assert.calledWithMatch(mockWallet.send, { attestation }); }); it('should successfully send with amount as string', async function () { @@ -816,6 +824,21 @@ describe('SendCoins V2 codec tests', function () { assert.strictEqual(decoded.amount, validBody.amount); }); + it('should preserve optional attestation in the request body schema', function () { + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }; + const decoded = assertDecode(t.type(SendCoinsRequestBody), { + address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', + amount: '1000', + attestation, + }); + assert.deepStrictEqual(decoded.attestation, attestation); + }); + it('should validate body with amount as string', function () { const validBody = { address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', diff --git a/modules/express/test/unit/typedRoutes/sendmany.ts b/modules/express/test/unit/typedRoutes/sendmany.ts index bf8699eb3e1..0d3a5044e8e 100644 --- a/modules/express/test/unit/typedRoutes/sendmany.ts +++ b/modules/express/test/unit/typedRoutes/sendmany.ts @@ -50,6 +50,13 @@ describe('SendMany V2 codec tests', function () { txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', }; + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; + afterEach(function () { sinon.restore(); }); @@ -67,12 +74,13 @@ describe('SendMany V2 codec tests', function () { }, ], walletPassphrase: 'test_passphrase_12345', + attestation, }; // Create mock wallet with sendMany method const mockWallet = { sendMany: sinon.stub().resolves(mockSendManyResponse), - _wallet: { multisigType: 'onchain' }, + _wallet: { multisigType: 'tss' }, }; // Stub the wallets().get() chain @@ -113,6 +121,7 @@ describe('SendMany V2 codec tests', function () { // Verify that the correct BitGoJS methods were called assert.strictEqual(walletsGetStub.calledOnce, true); assert.strictEqual(mockWallet.sendMany.calledOnce, true); + sinon.assert.calledWithMatch(mockWallet.sendMany, { attestation }); }); it('should successfully send with fee parameters', async function () { @@ -1123,6 +1132,20 @@ describe('SendMany V2 codec tests', function () { assert.strictEqual(decoded.recipients[0].amount, validBody.recipients[0].amount); }); + it('should preserve optional attestation in the request body schema', function () { + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }; + const decoded = assertDecode(t.type(SendManyRequestBody), { + recipients: [{ address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', amount: '1000' }], + attestation, + }); + assert.deepStrictEqual(decoded.attestation, attestation); + }); + it('should validate body with amount as string', function () { const validBody = { recipients: [ diff --git a/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts b/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts index 4c39919985b..990abf3adcb 100644 --- a/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts +++ b/modules/express/test/unit/typedRoutes/walletTxSignTSS.ts @@ -30,6 +30,13 @@ describe('WalletTxSignTSS codec tests', function () { '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', }; + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; + afterEach(function () { sinon.restore(); }); @@ -74,6 +81,7 @@ describe('WalletTxSignTSS codec tests', function () { }, walletPassphrase: 'test_passphrase_12345', apiVersion: 'lite' as const, + attestation, }; // Create mock wallet with ensureCleanSigSharesAndSignTransaction method @@ -116,6 +124,7 @@ describe('WalletTxSignTSS codec tests', function () { assert.strictEqual(mockCoin.wallets.calledOnce, true); assert.strictEqual(walletsGetStub.calledOnceWith({ id: walletId }), true); assert.strictEqual(mockWallet.ensureCleanSigSharesAndSignTransaction.calledOnce, true); + sinon.assert.calledWithMatch(mockWallet.ensureCleanSigSharesAndSignTransaction, { attestation }); }); it('should successfully sign a half-signed TSS wallet transaction', async function () { @@ -1168,6 +1177,17 @@ describe('WalletTxSignTSS codec tests', function () { assert.strictEqual(decoded.derivationSeed, validBody.derivationSeed); }); + it('should preserve optional attestation in the request body schema', function () { + const attestation = { + signature: 'signature', + credentialId: 'credential-id', + clientDataJSON: 'client-data-json', + authenticatorData: 'authenticator-data', + }; + const decoded = assertDecode(t.partial(WalletTxSignTSSBody), { attestation }); + assert.deepStrictEqual(decoded.attestation, attestation); + }); + it('should validate empty body', function () { const validBody = {}; const decoded = assertDecode(t.partial(WalletTxSignTSSBody), validBody); diff --git a/modules/sdk-core/src/bitgo/tss/common.ts b/modules/sdk-core/src/bitgo/tss/common.ts index 56dbbd3058c..934292202ba 100644 --- a/modules/sdk-core/src/bitgo/tss/common.ts +++ b/modules/sdk-core/src/bitgo/tss/common.ts @@ -2,6 +2,7 @@ import assert from 'assert'; import openpgp from 'openpgp'; import { MPCv2SigningState } from '@bitgo/public-types'; +import type { AttestationPayload } from '../wallet/BuildParams'; import { BitGoBase } from '../bitgoBase'; import { TxRequestChallengeResponse } from './types'; import { MPCAlgorithm } from '../baseCoin'; @@ -82,6 +83,7 @@ export async function getTxRequest( * @param mpcAlgorithm * @param apiMode * @param {IRequestTracer} reqId - the request tracer request id + * @param attestation Optional withdrawal attestation; include only on the first signing request. * @returns {Promise} - a Signature Share */ export async function sendSignatureShare( @@ -94,7 +96,8 @@ export async function sendSignatureShare( mpcAlgorithm: 'eddsa' | 'ecdsa' = 'eddsa', apiMode: 'full' | 'lite' = 'lite', userPublicGpgKey?: string, - reqId?: IRequestTracer + reqId?: IRequestTracer, + attestation?: AttestationPayload ): Promise { let addendum = ''; switch (requestType) { @@ -112,14 +115,14 @@ export async function sendSignatureShare( const urlPath = '/wallet/' + walletId + '/txrequests/' + txRequestId + addendum + '/signatureshares'; const reqTracer = reqId || new RequestTracer(); bitgo.setRequestTracer(reqTracer); - // TODO(WCN-541): add optional attestation pass-through for MPC /signatureshares, first round - // only (deferred until multisig attestation, WCN-539, is verified end-to-end on staging). + // Attestation is only attached to first-round withdrawal shares. return bitgo .post(bitgo.url(urlPath, 2)) .send({ signatureShare, signerShare, userPublicGpgKey, + ...(attestation !== undefined && { attestation }), }) .result(); } @@ -146,6 +149,7 @@ export async function sendSignatureShare( * @param signingType - Optional protocol selector for coins with more than one signing * protocol ('redPallasRerand' for the RedPallas rerand rounds); only valid for * MPCv2 + redpallas + * @param attestation Optional withdrawal attestation; include only on the first signing request. * @returns {Promise} - the latest TxRequest, with BitGo's shares echoed back */ export async function sendSignatureShareV2( @@ -159,7 +163,8 @@ export async function sendSignatureShareV2( signerShare?: string, multisigTypeVersion?: 'MPCv2' | undefined, reqId?: IRequestTracer, - signingType?: 'redPallasRerand' + signingType?: 'redPallasRerand', + attestation?: AttestationPayload ): Promise { if (signingType !== undefined && (multisigTypeVersion !== 'MPCv2' || mpcAlgorithm !== 'redpallas')) { throw new Error(`Invalid signingType ${signingType}: only supported for MPCv2 redpallas`); @@ -181,6 +186,7 @@ export async function sendSignatureShareV2( signatureShares, signerShare, signerGpgPublicKey, + ...(attestation !== undefined && { attestation }), }; const reqTracer = reqId || new RequestTracer(); bitgo.setRequestTracer(reqTracer); diff --git a/modules/sdk-core/src/bitgo/tss/ecdsa/ecdsa.ts b/modules/sdk-core/src/bitgo/tss/ecdsa/ecdsa.ts index 0160464b7bc..77f179180a8 100644 --- a/modules/sdk-core/src/bitgo/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/src/bitgo/tss/ecdsa/ecdsa.ts @@ -1,3 +1,4 @@ +import type { AttestationPayload } from '../../wallet/BuildParams'; import { Ecdsa } from './../../../account-lib/mpc/tss'; import { AShare, @@ -215,7 +216,8 @@ export async function sendShareToBitgo( privateShareProof?: string, publicShare?: string, userPublicGpgKey?: string, - reqId?: IRequestTracer + reqId?: IRequestTracer, + attestation?: AttestationPayload ): Promise { if (shareType !== SendShareType.SShare && share.i !== ShareKeyPosition.BITGO) { throw new Error('Invalid Share, is not from User to Bitgo'); @@ -241,7 +243,8 @@ export async function sendShareToBitgo( 'ecdsa', 'full', userPublicGpgKey, - reqId + reqId, + attestation ); responseFromBitgo = await getBitgoToUserLatestShare( bitgo, diff --git a/modules/sdk-core/src/bitgo/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/tss/eddsa/eddsa.ts index 3a028852b26..30a89b547f1 100644 --- a/modules/sdk-core/src/bitgo/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/tss/eddsa/eddsa.ts @@ -1,3 +1,4 @@ +import type { AttestationPayload } from '../../wallet/BuildParams'; import assert from 'assert'; import openpgp from 'openpgp'; import sodium from 'libsodium-wrappers-sumo'; @@ -207,7 +208,8 @@ export async function offerUserToBitgoRShare( encryptedSignerShare: string, apiMode: 'full' | 'lite' = 'lite', reqId?: IRequestTracer, - requestType: RequestType = RequestType.tx + requestType: RequestType = RequestType.tx, + attestation?: AttestationPayload ): Promise { const rShare: RShare = userSignShare.rShares[ShareKeyPosition.BITGO]; if (_.isNil(rShare)) { @@ -232,7 +234,8 @@ export async function offerUserToBitgoRShare( 'eddsa', apiMode, undefined, - reqId + reqId, + attestation ); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index 83ac9a90122..8b18952e786 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -276,13 +276,17 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil * @param {string | TxRequest} txRequest - transaction request with unsigned transaction * @param {CustomRShareGeneratingFunction} externalSignerRShareGenerator a function that creates R shares in the EdDSA TSS flow * @param {CustomGShareGeneratingFunction} externalSignerGShareGenerator a function that creates G shares in the EdDSA TSS flow + * @param reqId - optional request tracer + * @param attestation - optional withdrawal proof, attached only to the first R-share request * @returns {Promise} - a signed tx request */ signEddsaTssUsingExternalSigner( txRequest: string | TxRequest, externalSignerCommitmentGenerator: CustomCommitmentGeneratingFunction, externalSignerRShareGenerator: CustomRShareGeneratingFunction, - externalSignerGShareGenerator: CustomGShareGeneratingFunction + externalSignerGShareGenerator: CustomGShareGeneratingFunction, + reqId?: IRequestTracer, + attestation?: TSSParams['attestation'] ): Promise { throw new Error('Method not implemented.'); } @@ -423,6 +427,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil apiVersion: apiVersion, preview, videoApprovers: params.videoApprovers, + ...(params.attestation !== undefined && { attestation: params.attestation }), }; const reqTracer = params.reqId || new RequestTracer(); @@ -528,6 +533,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil * Calls Bitgo API to create tx request. * * @private + * Message and typed-data txrequests do not carry withdrawal-intent attestation. */ private async createTxRequestBase( intentOptions: PopulatedIntentForTypedDataSigning | PopulatedIntentForMessageSigning, @@ -535,8 +541,6 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil preview?: boolean, reqId?: IRequestTracer ): Promise { - // TODO(WCN-541): add optional attestation pass-through for MPC /txrequests (deferred until - // multisig attestation, WCN-539, is verified end-to-end on staging). const whitelistedParams = { intent: { ...intentOptions, diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index b8b9e36bd21..0473a7ee397 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -3,6 +3,7 @@ import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../../ import { type ITransactionRecipient, KeychainsTriplet, ParsedTransaction, TransactionParams } from '../../baseCoin'; import { ApiKeyShare, Keychain, WebauthnKeyEncryptionInfo } from '../../keychain'; import { ApiVersion, Memo, WalletType, type PrebuildTransactionFeeInfo } from '../../wallet'; +import type { AttestationPayload } from '../../wallet/BuildParams'; import { EDDSA, GShare, Signature, SignShare } from '../../../account-lib/mpc/tss'; import { Signature as EcdsaSignature } from '../../../account-lib/mpc/tss/ecdsa/types'; import { KeyShare } from './ecdsa'; @@ -362,6 +363,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase * enterprise's approved Video ID users. */ videoApprovers?: string[]; + /** Optional pass-through WebAuthn proof sent at the top level of a withdrawal txrequest. */ + attestation?: AttestationPayload; /** * MIST to redeem from the sender's address balance (SIP-58 / SUI address balances). * When set, the PTB includes tx.withdrawal() + redeem_funds() before the transfer. @@ -733,6 +736,7 @@ export type TSSParams = { reqId: IRequestTracer; apiVersion?: ApiVersion; txParams?: TransactionParams; + attestation?: AttestationPayload; }; export type TSSParamsForMessage = TSSParams & { @@ -767,6 +771,7 @@ export type TssSignTxExplicitRecipientParams = { apiVersion?: ApiVersion; recipientSource: typeof TssTxRecipientSource.Explicit; txParams: TransactionParamsWithMandatoryRecipients; + attestation?: AttestationPayload; }; export type TssSignTxResolvedRecipientParams = { @@ -775,6 +780,7 @@ export type TssSignTxResolvedRecipientParams = { apiVersion?: ApiVersion; recipientSource?: typeof TssTxRecipientSource.Resolved; txParams?: TransactionParams; + attestation?: AttestationPayload; }; /** @@ -962,7 +968,9 @@ export interface ITssUtils { txRequest: string | TxRequest, externalSignerCommitmentGenerator: CustomCommitmentGeneratingFunction, externalSignerRShareGenerator: CustomRShareGeneratingFunction, - externalSignerGShareGenerator: CustomGShareGeneratingFunction + externalSignerGShareGenerator: CustomGShareGeneratingFunction, + reqId?: IRequestTracer, + attestation?: TSSParams['attestation'] ): Promise; signEcdsaTssUsingExternalSigner( params: TSSParams | TSSParamsForMessage, diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts index 62438e8125b..3ef50fe19e4 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts @@ -714,7 +714,8 @@ export class EcdsaUtils extends BaseEcdsaUtils { step1SigningMaterial.privateShareProof, step1SigningMaterial.publicShare, step1SigningMaterial.userPublicGpgKey, - params.reqId + params.reqId, + requestType === RequestType.tx ? params.attestation : undefined )) as Omit; // WP/HSM does not return the initial challenge const step2Return = await externalSignerMuDeltaShareGenerator({ txRequest: txRequestObj, @@ -865,7 +866,8 @@ export class EcdsaUtils extends BaseEcdsaUtils { step1Return.privateShareProof, step1Return.publicShare, step1Return.userPublicGpgKey, - params.reqId + params.reqId, + requestType === RequestType.tx ? params.attestation : undefined )) as Omit; // WP/HSM does not return the initial challenge const step2Return = await this.createTssEcdsaStep2SigningMaterial({ diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 360e15bc54d..448dc632c59 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -1042,7 +1042,9 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { userGpgKey.publicKey, undefined, this.wallet.multisigTypeVersion(), - params.reqId + params.reqId, + undefined, + requestType === RequestType.tx && 'attestation' in params ? params.attestation : undefined ); assert(latestTxRequest.transactions || latestTxRequest.messages, 'Invalid txRequest Object'); @@ -1276,7 +1278,9 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { userGpgPubKey, undefined, this.wallet.multisigTypeVersion(), - reqId + reqId, + undefined, + params.attestation ); // round 2 diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index 21f2cfdbefc..46a22f0080f 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -658,7 +658,8 @@ export class EddsaUtils extends baseTSSUtils { externalSignerCommitmentGenerator: CustomCommitmentGeneratingFunction, externalSignerRShareGenerator: CustomRShareGeneratingFunction, externalSignerGShareGenerator: CustomGShareGeneratingFunction, - reqId?: IRequestTracer + reqId?: IRequestTracer, + attestation?: TSSParamsWithPrv['attestation'] ): Promise { let txRequestResolved: TxRequest; let txRequestId: string; @@ -698,7 +699,9 @@ export class EddsaUtils extends baseTSSUtils { rShare, encryptedSignerShare.share, apiVersion, - reqId + reqId, + RequestType.tx, + attestation ); const bitgoToUserRShare = await getBitgoToUserRShare(this.bitgo, this.wallet.id(), txRequestId, reqId); const gSignShareTransactionParams = { @@ -818,7 +821,8 @@ export class EddsaUtils extends baseTSSUtils { userToBitgoEncryptedSignerShare, apiVersion, params.reqId, - requestType + requestType, + requestType === RequestType.tx ? params.attestation : undefined ); const bitgoToUserRShare = await getBitgoToUserRShare( 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 411a5802877..d6b73e963bb 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -629,7 +629,9 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { userGpgKey.publicKey, undefined, this.wallet.multisigTypeVersion(), - params.reqId + params.reqId, + undefined, + requestType === RequestType.tx && 'attestation' in params ? params.attestation : undefined ); assert(latestTxRequest.transactions || latestTxRequest.messages, 'Invalid txRequest object after round 1'); @@ -1222,7 +1224,9 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { userGpgPubKey, undefined, this.wallet.multisigTypeVersion(), - reqId + reqId, + undefined, + params.attestation ); // round 2 diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 3b1eb18aeb5..2d1e956adec 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -73,13 +73,12 @@ export const BuildParamsOffchain = t.partial({ * WebAuthn attestation proving a passkey user signed off on this withdrawal intent. * Pure pass-through — the SDK does not validate or interpret this payload. * - * @bitgo/public-types' TxSendBody now declares this field natively (WCN-539), so /tx/send and + * @bitgo/public-types' TxSendBody now declares this field natively, so /tx/send and * /tx/initiate no longer need a local copy. This one remains for BuildParams (custodial * /tx/build), which has no upstream equivalent yet. * - * TODO(WCN-541): add attestation pass-through for MPC (/txrequests, /signatureshares) and - * /pendingapprovals — see the TODO(WCN-541) markers in baseTSSUtils.ts, common.ts, and - * pendingApproval.ts. + * Message and typed-data MPC request pass-through remains out of scope. + * MPC withdrawal txrequest/signature-share pass-through is implemented here; pending approvals use their own path. */ export const AttestationPayload = t.type({ signature: t.string, @@ -175,7 +174,7 @@ export const BuildParams = t.exact( wrapParams: t.unknown, // ERC-7984 unwrap (unshield): { tokenName, amount } passthrough to WP unwrapParams: t.unknown, - // WebAuthn attestation for the withdrawal intent (WCN-539) — pass-through only. + // WebAuthn attestation for the withdrawal intent — pass-through only. attestation: AttestationPayload, }), ]) diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 671b8e2302f..107962047e9 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1,3 +1,4 @@ +import type { AttestationPayload } from './BuildParams'; import { EncryptionVersion, IRequestTracer } from '../../api'; import { CreateLightningInvoiceParams, LightningInvoiceResponse } from '../../lightning'; import { @@ -39,7 +40,6 @@ import { TxRequest, } from '../utils'; import { SerializedNtilde } from '../../account-lib/mpc/tss/ecdsa/types'; -import { AttestationPayload } from './BuildParams'; import { IAddressBook } from '../address-book'; import { WalletUser, AddressQueryResult } from '@bitgo/public-types'; import { SubmitTransactionResponse } from '../inscriptionBuilder'; @@ -219,7 +219,7 @@ export interface PrebuildTransactionOptions { idfUserId?: string; idfVersion?: number; comment?: string; - /** WebAuthn attestation for the withdrawal intent (WCN-539) — pass-through only. */ + /** WebAuthn proof for a withdrawal; pass-through only. */ attestation?: AttestationPayload; [index: string]: unknown; tokenName?: string; @@ -419,6 +419,8 @@ export interface WalletSignBaseOptions { } export interface WalletSignTransactionOptions extends WalletSignBaseOptions { + /** Optional WebAuthn proof for the first TSS withdrawal-signing round; pass-through only. */ + attestation?: AttestationPayload; txPrebuild?: TransactionPrebuild; customCommitmentGeneratingFunction?: CustomCommitmentGeneratingFunction; customRShareGeneratingFunction?: CustomRShareGeneratingFunction; @@ -1001,7 +1003,7 @@ export interface SubmitTransactionOptions { }; comment?: string; txRequestId?: string; - /** WebAuthn attestation for the withdrawal intent (WCN-539) — pass-through only. */ + /** WebAuthn attestation for the withdrawal intent — pass-through only. */ attestation?: AttestationPayload; } @@ -1013,6 +1015,8 @@ export interface SendOptions { message?: string; walletPassphrase?: string; prv?: string; + /** Optional WebAuthn proof for a withdrawal; pass-through only. */ + attestation?: AttestationPayload; minConfirms?: number; enforceMinConfirmsForChange?: boolean; custodianTransactionId?: string; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 97306290fdd..ce66b8f94d8 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2804,12 +2804,16 @@ export class Wallet implements IWallet { customSigningFunction: params.customSigningFunction, }); + const previewPrebuildTx = + isPrebuildTransactionResult(params.prebuildTx) && params.prebuildTx.buildParams?.preview === true + ? params.prebuildTx + : undefined; let txPrebuildQuery: Promise; - if (isPrebuildTransactionResult(params.prebuildTx) && params.prebuildTx.buildParams?.preview) { + if (previewPrebuildTx) { // If we prebuilt the txRequest with preview=true, then we should rebuild with preview=false to persist the request txPrebuildQuery = this.prebuildTransaction({ ...params, - ...{ ...params.prebuildTx.buildParams, preview: false }, + ...{ ...previewPrebuildTx.buildParams, preview: false }, }); } else { txPrebuildQuery = params.prebuildTx ? Promise.resolve(params.prebuildTx) : this.prebuildTransaction(params); @@ -2817,6 +2821,12 @@ export class Wallet implements IWallet { // the prebuild can be overridden by providing an explicit tx const txPrebuild = (await txPrebuildQuery) as PrebuildTransactionResult; + const createdAttestedTransferTxRequest = + this._wallet.multisigType === 'tss' && + !!txPrebuild.txRequestId && + txPrebuild.buildParams?.attestation !== undefined && + (!params.prebuildTx || previewPrebuildTx !== undefined) && + (txPrebuild.buildParams?.type === 'transfer' || txPrebuild.buildParams?.type === 'transfertoken'); try { await this.baseCoin.verifyTransaction({ @@ -2855,6 +2865,11 @@ export class Wallet implements IWallet { bitgoKeychain: keychains.length > 2 ? keychains[2] : null, reqId: params.reqId, }; + if (createdAttestedTransferTxRequest) { + // Wallet Platform consumes the nonce when creating the request; round 1 re-capture would fail. + delete signingParams.attestation; + } + if (this._wallet.multisigType === 'onchain') { signingParams.pubs = keychains.map((k) => { assert(k.pub); @@ -4547,6 +4562,7 @@ export class Wallet implements IWallet { isTestTransaction: params.isTestTransaction, isWalletConnectTx: params.isWalletConnectTx as boolean | undefined, videoApprovers: params.videoApprovers, + ...(params.attestation !== undefined && { attestation: params.attestation }), }, apiVersion, params.preview @@ -4565,6 +4581,7 @@ export class Wallet implements IWallet { unspents: params.unspents, sequenceId: params.sequenceId, videoApprovers: params.videoApprovers, + ...(params.attestation !== undefined && { attestation: params.attestation }), }, apiVersion, params.preview @@ -5096,7 +5113,8 @@ export class Wallet implements IWallet { params.customCommitmentGeneratingFunction, params.customRShareGeneratingFunction, params.customGShareGeneratingFunction, - reqId + reqId, + params.attestation ); } catch (e) { debug('failed to sign transaction %O', e); @@ -5302,6 +5320,7 @@ export class Wallet implements IWallet { { txRequest: txRequestId, reqId: params.reqId || new RequestTracer(), + attestation: params.attestation, }, RequestType.tx, params.customPaillierModulusGeneratingFunction, @@ -5357,6 +5376,7 @@ export class Wallet implements IWallet { { txRequest: txRequestId, reqId: params.reqId || new RequestTracer(), + attestation: params.attestation, }, params.customEddsaMPCv2SigningRound1GenerationFunction, params.customEddsaMPCv2SigningRound2GenerationFunction, @@ -5404,6 +5424,7 @@ export class Wallet implements IWallet { { txRequest: txRequestId, reqId: params.reqId || new RequestTracer(), + attestation: params.attestation, }, params.customMPCv2SigningRound1GenerationFunction, params.customMPCv2SigningRound2GenerationFunction, @@ -5443,6 +5464,7 @@ export class Wallet implements IWallet { prv: params.prv, reqId: params.reqId || new RequestTracer(), apiVersion: params.apiVersion, + ...(params.attestation !== undefined && { attestation: params.attestation }), }); } catch (e) { debug('failed to sign transaction %O', e); diff --git a/modules/sdk-core/test/unit/bitgo/tss/common.ts b/modules/sdk-core/test/unit/bitgo/tss/common.ts index fd79870ec44..69e3014fce6 100644 --- a/modules/sdk-core/test/unit/bitgo/tss/common.ts +++ b/modules/sdk-core/test/unit/bitgo/tss/common.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { sendSignatureShareV2 } from '../../../../src/bitgo/tss/common'; -import { BitGoBase, RequestType, TxRequest, MPCAlgorithm } from '../../../../src'; +import { sendSignatureShare, sendSignatureShareV2 } from '../../../../src/bitgo/tss/common'; +import { BitGoBase, RequestType, TxRequest, MPCAlgorithm, SignatureShareType } from '../../../../src'; describe('sendSignatureShareV2 request type dispatch', function () { afterEach(function () { @@ -76,3 +76,81 @@ describe('sendSignatureShareV2 request type dispatch', function () { assert.strictEqual(await captureRequestType('redpallas', undefined), ''); }); }); + +describe('signature share attestation forwarding', function () { + const attestation = { + signature: 'sig', + credentialId: 'credential', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; + + afterEach(function () { + sinon.restore(); + }); + + it('conditionally includes attestation in legacy signature-share requests', async function () { + for (const includeAttestation of [true, false]) { + let body: Record | undefined; + const send = sinon.stub().callsFake((requestBody: Record) => { + body = requestBody; + return { result: sinon.stub().resolves({}) }; + }); + const bitgo = { + url: sinon.stub().returns('/mock/url'), + post: sinon.stub().returns({ send }), + setRequestTracer: sinon.stub(), + } as unknown as BitGoBase; + await sendSignatureShare( + bitgo, + 'walletId', + 'txRequestId', + { from: SignatureShareType.USER, to: SignatureShareType.BITGO, share: 'share' }, + RequestType.tx, + undefined, + 'eddsa', + 'lite', + undefined, + undefined, + includeAttestation ? attestation : undefined + ); + assert.strictEqual(Object.prototype.hasOwnProperty.call(body, 'attestation'), includeAttestation); + if (includeAttestation) { + assert.deepStrictEqual(body?.attestation, attestation); + } + } + }); + + it('conditionally includes attestation in MPCv2 signature-share requests', async function () { + for (const includeAttestation of [true, false]) { + let body: Record | undefined; + const send = sinon.stub().callsFake((requestBody: Record) => { + body = requestBody; + return { result: sinon.stub().resolves({} as TxRequest) }; + }); + const bitgo = { + url: sinon.stub().returns('/mock/url'), + post: sinon.stub().returns({ send }), + setRequestTracer: sinon.stub(), + } as unknown as BitGoBase; + await sendSignatureShareV2( + bitgo, + 'walletId', + 'txRequestId', + [], + RequestType.tx, + 'ecdsa', + 'signerGpgPublicKey', + undefined, + 'MPCv2', + undefined, + undefined, + includeAttestation ? attestation : undefined + ); + assert.strictEqual(Object.prototype.hasOwnProperty.call(body, 'attestation'), includeAttestation); + if (includeAttestation) { + assert.deepStrictEqual(body?.attestation, attestation); + } + } + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 4068a064526..0e8217f61db 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -19,6 +19,8 @@ import { EcdsaMPCv2Utils, EcdsaMPCv2KeyGenCallbacks, IBaseCoin, + IWallet, + RequestTracer, SignatureShareRecord, SignatureShareType, TxRequest, @@ -1382,3 +1384,108 @@ describe('EcdsaMPCv2Utils.createKeychainsWithExternalSigner', function () { ); }); }); + +describe('EcdsaMPCv2Utils.signEcdsaMPCv2TssUsingExternalSigner attestation', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should submit attestation only with the first external signer request', async () => { + const walletId = 'ecdsa-external-signer-wallet'; + const signatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ type: 'round1Input', data: {} }), + }; + const txRequest: TxRequest = { + txRequestId: 'ecdsa-external-signer-txrequest', + walletId, + enterpriseId: 'enterprise-id', + apiVersion: 'full', + transactions: [ + { + unsignedTx: { derivationPath: 'm/0', signableHex: 'deadbeef' }, + signatureShares: [], + }, + ], + } as unknown as TxRequest; + const roundResponse: TxRequest = { + ...txRequest, + transactions: [ + { + unsignedTx: { serializedTxHex: 'deadbeef', derivationPath: 'm/0', signableHex: 'deadbeef' }, + signatureShares: [signatureShare], + state: 'initialized', + }, + ], + }; + const finalTxRequest = txRequest; + const responses = [roundResponse, roundResponse, roundResponse, finalTxRequest]; + const requestBodies: unknown[] = []; + let postIndex = 0; + const postStub = sinon.stub().callsFake(() => { + const response = responses[postIndex++]; + if (!response) { + throw new Error('Unexpected POST request in external signer test'); + } + return { + send: (body?: unknown) => { + if (body !== undefined) { + requestBodies.push(body); + } + return { result: sinon.stub().resolves(response) }; + }, + }; + }); + const mockBitgo = { + setRequestTracer: sinon.stub(), + url: sinon.stub().callsFake((path: string) => path), + post: postStub, + } as unknown as BitGoBase; + const mockCoin = { getMPCAlgorithm: sinon.stub().returns('ecdsa') } as unknown as IBaseCoin; + const mockWallet = { + id: sinon.stub().returns(walletId), + multisigTypeVersion: sinon.stub().returns('MPCv2'), + } as unknown as IWallet; + const utils = new EcdsaMPCv2Utils(mockBitgo, mockCoin, mockWallet); + Object.defineProperty(utils, 'pickBitgoPubGpgKeyForSigning', { + value: sinon.stub().resolves({ armor: () => 'bitgo-gpg-public-key' }), + }); + + const attestation = { + signature: 'webauthn-signature', + credentialId: 'credential-123', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; + const round1Generator = async () => ({ + signatureShareRound1: signatureShare, + userGpgPubKey: 'user-gpg-public-key', + encryptedRound1Session: 'encrypted-round1-session', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-private-key', + }); + const round2Generator = async () => ({ + signatureShareRound2: signatureShare, + encryptedRound2Session: 'encrypted-round2-session', + }); + const round3Generator = async () => ({ signatureShareRound3: signatureShare }); + + await utils.signEcdsaMPCv2TssUsingExternalSigner( + { txRequest, reqId: new RequestTracer(), attestation }, + round1Generator, + round2Generator, + round3Generator + ); + + assert.strictEqual(requestBodies.length, 3, 'all three /sign request bodies should be captured'); + for (const [index, body] of requestBodies.entries()) { + assert.ok(body !== null && typeof body === 'object', `round ${index + 1} body should be an object`); + if (index === 0) { + assert.ok('attestation' in body, 'round 1 /sign body must include attestation'); + assert.deepStrictEqual(body.attestation, attestation); + } else { + assert.ok(!('attestation' in body), `round ${index + 1} /sign body must omit attestation`); + } + } + }); +}); 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 dd46d159e32..e05fe33146b 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 @@ -2245,8 +2245,15 @@ describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => { }; // Returns a chain compatible with: bitgo.post(url).send(body).result() - const makePostChain = (response: TxRequest): BitGoRequest => - ({ send: () => ({ result: sinon.stub().resolves(response) }) } as unknown as BitGoRequest); + const makePostChain = (response: TxRequest, requestBodies?: unknown[]): BitGoRequest => + ({ + send: (body?: unknown) => { + if (requestBodies && body !== undefined) { + requestBodies.push(body); + } + return { result: sinon.stub().resolves(response) }; + }, + } as unknown as BitGoRequest); // Returns a chain compatible with: bitgo.get(url).query(params).retry(n).result() const makeGetChain = (txRequests: TxRequest[]): BitGoRequest<{ txRequests: TxRequest[] }> => @@ -2294,6 +2301,55 @@ describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => { sandbox.restore(); }); + it('should submit attestation only with the first external signer request', async () => { + const finalTxRequest = { ...mockTxRequest, txRequestId }; + const requestBodies: unknown[] = []; + + // sendSignatureShareV2 runs for each signing round; sendTxRequest has no body. + (mockBitgo.post as sinon.SinonStub) + .onCall(0) + .returns(makePostChain(mockTxRequest, requestBodies)) + .onCall(1) + .returns(makePostChain(mockTxRequestRound2, requestBodies)) + .onCall(2) + .returns(makePostChain(mockTxRequestRound2, requestBodies)) + .onCall(3) + .returns(makePostChain(finalTxRequest, requestBodies)); + + const attestation = { + signature: 'webauthn-signature', + credentialId: 'credential-123', + clientDataJSON: 'client-data', + authenticatorData: 'authenticator-data', + }; + const round1Generator = sinon.stub().resolves({ + signatureShareRound1: dummyShare, + userGpgPubKey: bitgoGpgKeyPair.publicKey, + encryptedRound1Session: 'r1', + encryptedUserGpgPrvKey: 'key', + }); + const round2Generator = sinon.stub().resolves({ signatureShareRound2: dummyShare, encryptedRound2Session: 'r2' }); + const round3Generator = sinon.stub().resolves({ signatureShareRound3: dummyShare }); + + await eddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner( + { txRequest: mockTxRequest, reqId: new RequestTracer(), attestation }, + round1Generator as unknown as CustomEddsaMPCv2SigningRound1GeneratingFunction, + round2Generator as unknown as CustomEddsaMPCv2SigningRound2GeneratingFunction, + round3Generator as unknown as CustomEddsaMPCv2SigningRound3GeneratingFunction + ); + + assert.strictEqual(requestBodies.length, 3, 'all three /sign request bodies should be captured'); + for (const [index, body] of requestBodies.entries()) { + assert.ok(body !== null && typeof body === 'object', `round ${index + 1} body should be an object`); + if (index === 0) { + assert.ok('attestation' in body, 'round 1 /sign body must include attestation'); + assert.deepStrictEqual(body.attestation, attestation); + } else { + assert.ok(!('attestation' in body), `round ${index + 1} /sign body must omit attestation`); + } + } + }); + it('should call all 3 generators and return the final tx request', async () => { const finalTxRequest = { ...mockTxRequest, txRequestId };