From b5605742a17f465f27729ae132dfdc975dda2936 Mon Sep 17 00:00:00 2001 From: Gordon Zhang Date: Mon, 2 Nov 2026 11:39:08 -0500 Subject: [PATCH] feat(sdk-core): forward attestation through signAndSendTxRequest signAndSendTxRequest is how clients sign an existing TSS txRequest that was created, approved, or abandoned earlier: the original initiator resuming, or another authorized wallet user signing. It had no way to carry the current signer's WebAuthn intent attestation, although signTransaction/signTxRequest already send one on the first signing round only. Add an optional attestation to SignAndSendTxRequestOptions and forward it via getUserKeyAndSignTssTransaction to signTransaction. Because signAndSendTxRequest clears signature shares first, every call is a first-round sign, so the proof is never replayed into a later round. It is not forwarded to the lite submitTransaction call: round one has already consumed its nonce. Omitting attestation leaves request bodies unchanged, so Wallet Platform can still fall back to a proof the signer stored at creation or approval. Ticket: WCN-3153 Co-Authored-By: Claude Opus 5.5 --- modules/bitgo/test/v2/unit/wallet.ts | 73 ++++++++++++++++++++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 5 ++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 9 ++- 3 files changed, 86 insertions(+), 1 deletion(-) 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