diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 60b563514a..dd418def04 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -4643,6 +4643,13 @@ describe('V2 Wallet:', function () { }); }); + const exampleAttestation = { + signature: 'sig', + credentialId: 'cred', + clientDataJSON: 'cdj', + authenticatorData: 'ad', + }; + describe('getUserKeyAndSignTssTransaction', function () { ['eddsa', 'ecdsa'].forEach((keyCurve: string) => { describe(keyCurve, () => { @@ -4681,6 +4688,29 @@ describe('V2 Wallet:', function () { getKeysStub.calledOnce.should.be.true(); signTransactionStub.calledOnce.should.be.true(); + signTransactionStub.firstCall.args[0].should.not.have.property('attestation'); + }); + + it('should forward the attestation to signTransaction', async function () { + getKeysStub.resolves([ + { + commonKeychain: 'test', + id: '', + pub: '', + type: 'tss', + encryptedPrv: + '{"iv":"15FsbDVI1zG9OggD8YX+Hg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"hHbNH3Sz/aU=","ct":"WoNVKz7afiRxXI2w/YkzMdMyoQg/B15u1Q8aQgi96jJZ9wk6TIaSEc6bXFH3AHzD9MdJCWJQUpRhoQc/rgytcn69scPTjKeeyVMElGCxZdFVS/psQcNE+lue3//2Zlxj+6t1NkvYO+8yAezSMRBK5OdftXEjNQI="}', + }, + ]); + + await wallet.getUserKeyAndSignTssTransaction({ + walletPassphrase: TestBitGo.V2.TEST_ETH_WALLET_PASSPHRASE as string, + txRequestId: 'id', + attestation: exampleAttestation, + }); + + signTransactionStub.calledOnce.should.be.true(); + signTransactionStub.firstCall.args[0].should.have.property('attestation', exampleAttestation); }); it('should throw if the keychain doesnt have the encryptedKey', async function () { @@ -4770,6 +4800,49 @@ describe('V2 Wallet:', function () { sandbox.assert.calledOnce(getUserKeyAndSignTssTxSpy); signedTx.should.deepEqual(exampleSignedTx); }); + + it('should forward the attestation to first-round signing only, not the lite submit', async function () { + const deleteSignatureSharesSpy = sandbox.stub(TssUtils.prototype, 'deleteSignatureShares'); + const getUserKeyAndSignTssTxSpy = sandbox.stub(tssSolWallet, 'getUserKeyAndSignTssTransaction'); + getUserKeyAndSignTssTxSpy.resolves(exampleSignedTx); + const submitTxSpy = sandbox.stub(tssSolWallet, 'submitTransaction'); + submitTxSpy.resolves(exampleSignedTx); + + await tssSolWallet.signAndSendTxRequest({ + walletPassphrase: 'passphrase', + txRequestId: 'id', + isTxRequestFull: false, + attestation: exampleAttestation, + }); + + sandbox.assert.callOrder(deleteSignatureSharesSpy, getUserKeyAndSignTssTxSpy, submitTxSpy); + sandbox.assert.calledOnce(getUserKeyAndSignTssTxSpy); + sandbox.assert.calledWithExactly(getUserKeyAndSignTssTxSpy, { + walletPassphrase: 'passphrase', + txRequestId: 'id', + attestation: exampleAttestation, + }); + sandbox.assert.calledOnce(submitTxSpy); + sandbox.assert.calledWithExactly(submitTxSpy, { txRequestId: 'id' }); + }); + + it('should not send an attestation key when none is provided', async function () { + sandbox.stub(TssUtils.prototype, 'deleteSignatureShares'); + const getUserKeyAndSignTssTxSpy = sandbox.stub(tssSolWallet, 'getUserKeyAndSignTssTransaction'); + getUserKeyAndSignTssTxSpy.resolves(exampleSignedTx); + + await tssSolWallet.signAndSendTxRequest({ + walletPassphrase: 'passphrase', + txRequestId: 'id', + isTxRequestFull: true, + }); + + sandbox.assert.calledOnce(getUserKeyAndSignTssTxSpy); + sandbox.assert.calledWithExactly(getUserKeyAndSignTssTxSpy, { + walletPassphrase: 'passphrase', + txRequestId: 'id', + }); + }); }); describe('Message Signing', function () { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 107962047e..2c34c679ac 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -470,6 +470,11 @@ export interface SignAndSendTxRequestOptions { txRequestId: string; walletPassphrase: string; isTxRequestFull: boolean; + /** + * Optional WebAuthn proof from the current signer for the request's withdrawal intent; + * pass-through only. Sent on the first signing round only, never on the lite submit. + */ + attestation?: AttestationPayload; } export interface SignAndSendMessageTxRequestOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 3a37110801..b9e4da44e2 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -58,7 +58,7 @@ import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa'; import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa'; import { RedpallasMPCv2Utils } from '../utils/tss/redpallas'; import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest'; -import { buildParamKeys, BuildParams, ERC7984_INTENT_TX_TYPES } from './BuildParams'; +import { AttestationPayload, buildParamKeys, BuildParams, ERC7984_INTENT_TX_TYPES } from './BuildParams'; import { fetchRootKeychainForSafeChild, isSafeChildPublicOnlyKeychain, @@ -2315,9 +2315,12 @@ export class Wallet implements IWallet { async getUserKeyAndSignTssTransaction({ txRequestId, walletPassphrase, + attestation, }: { txRequestId: string; walletPassphrase: string; + /** Optional WebAuthn proof for the first TSS withdrawal-signing round; pass-through only. */ + attestation?: AttestationPayload; }): Promise { if (this._wallet.multisigType !== 'tss') { throw new Error('getUserKeyAndSignTssTransaction is only supported for TSS wallets'); @@ -2338,6 +2341,7 @@ export class Wallet implements IWallet { walletPassphrase, reqId, keychain: userKeychain, + ...(attestation !== undefined && { attestation }), }); } @@ -5149,9 +5153,12 @@ export class Wallet implements IWallet { public async signAndSendTxRequest(params: SignAndSendTxRequestOptions): Promise { await this.tssUtils?.deleteSignatureShares(params.txRequestId); + // Clearing shares above resets the request to its first signing round, so the attestation + // rides on round one. It is not sent with the lite submit below: round one consumes its nonce. const ret = await this.getUserKeyAndSignTssTransaction({ walletPassphrase: params.walletPassphrase, txRequestId: params.txRequestId, + ...(params.attestation !== undefined && { attestation: params.attestation }), }); if (!params.isTxRequestFull) { // It is assumed that if its not a full tx request, then it is a lite tx request