diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 40f324e713..520b75dee5 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -33,6 +33,10 @@ import { AuditDecryptedKeyParams, extractCommonKeychain, TssVerifyAddressOptions, + getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, + signEddsaMpcV2RecoveryTx, + EddsaSigningMaterial, + decryptKeychainPrivateKey, } from '@bitgo/sdk-core'; import { KeyPair as AdaKeyPair, Transaction, TransactionBuilderFactory, Utils } from './lib'; import type { Asset } from './lib/transaction'; @@ -370,7 +374,7 @@ export class Ada extends BaseCoin { * @returns {MPCTx | MPCSweepTxs} array of the serialized transaction hex strings and indices * of the addresses being swept */ - async recover(params: MPCRecoveryOptions): Promise { + async recover(params: MPCRecoveryOptions, precomputedMaterial?: EddsaSigningMaterial): Promise { if (!params.bitgoKey) { throw new Error('missing bitgoKey'); } @@ -447,53 +451,50 @@ export class Ada extends BaseCoin { let serializedTx = unsignedTransaction.toBroadcastFormat(); if (!isUnsignedSweep) { - if (!params.userKey) { - throw new Error('missing userKey'); - } - if (!params.backupKey) { - throw new Error('missing backupKey'); - } - if (!params.walletPassphrase) { - throw new Error('missing wallet passphrase'); - } + assert(params.userKey, 'missing userKey'); + assert(params.backupKey, 'missing backupKey'); + assert(params.walletPassphrase, 'missing wallet passphrase'); // Clean up whitespace from entered values const userKey = params.userKey.replace(/\s/g, ''); const backupKey = params.backupKey.replace(/\s/g, ''); - - // Decrypt private keys from KeyCard values - let userPrv; - try { - userPrv = await this.bitgo.decrypt({ - input: userKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e.message}`); - } - /** TODO BG-52419 Implement Codec for parsing */ - const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; - - let backupPrv; - try { - backupPrv = await this.bitgo.decrypt({ - input: backupKey, - password: params.walletPassphrase, + const adaKeyPair = new AdaKeyPair({ pub: accountId }); + const signingMaterial = + precomputedMaterial ?? (await this.getEddsaSigningMaterial(userKey, params.walletPassphrase)); + + if (signingMaterial.version === 'v2') { + const signature = await this.signAdaMpcV2Recovery({ + message: unsignedTransaction.signablePayload, + userKey: signingMaterial.encryptedUserKey, + backupKey, + walletPassphrase: params.walletPassphrase, + bitgoKey, + derivationPath: currPath, + bitgo: this.bitgo, }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); + txBuilder.addSignature({ pub: adaKeyPair.getKeys().pub }, signature); + } else { + /** TODO BG-52419 Implement Codec for parsing */ + const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial; + const backupPrv = await decryptKeychainPrivateKey( + this.bitgo, + { encryptedPrv: backupKey }, + params.walletPassphrase + ); + if (!backupPrv) { + throw new Error('Error decrypting backup keychain: invalid password or corrupted key'); + } + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + + // add signature + const signatureHex = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + currPath, + unsignedTransaction + ); + txBuilder.addSignature({ pub: adaKeyPair.getKeys().pub }, signatureHex); } - const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; - - // add signature - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, - currPath, - unsignedTransaction - ); - const adaKeyPair = new AdaKeyPair({ pub: accountId }); - txBuilder.addSignature({ pub: adaKeyPair.getKeys().pub }, signatureHex); const signedTransaction = await txBuilder.build(); serializedTx = signedTransaction.toBroadcastFormat(); } else { @@ -574,6 +575,14 @@ export class Ada extends BaseCoin { seed: params.seed, }; const { address: baseAddress } = await this.getAdaAddressAndAccountId(addressParams); + + // Detect signing material once to avoid re-decrypting the keycard on every loop iteration. + let signingMaterial: EddsaSigningMaterial | undefined; + if (params.walletPassphrase) { + assert(params.userKey, 'missing userKey'); + signingMaterial = await this.getEddsaSigningMaterial(params.userKey, params.walletPassphrase); + } + const consolidationTransactions: any[] = []; let lastScanIndex = startIdx; for (let i = startIdx; i < endIdx; i++) { @@ -589,7 +598,7 @@ export class Ada extends BaseCoin { let recoveryTransaction; try { - recoveryTransaction = await this.recover(recoverParams); + recoveryTransaction = await this.recover(recoverParams, signingMaterial); } catch (e) { if ( e.message === 'Did not find address with funds to recover.' || @@ -690,6 +699,18 @@ export class Ada extends BaseCoin { return new TransactionBuilderFactory(coins.get(this.getBaseChain())); } + /** + * Detects whether a keycard's decrypted plaintext is MPCv1 JSON or MPCv2 CBOR. + * Unsigned sweeps (no walletPassphrase) have no keycard to inspect and default to MPCv1. + */ + protected async getEddsaSigningMaterial(userKey: string, walletPassphrase: string): Promise { + return sharedGetEddsaSigningMaterial(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo); + } + + protected async signAdaMpcV2Recovery(params: Parameters[0]): Promise { + return signEddsaMpcV2RecoveryTx(params); + } + /** inherited doc */ setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void { intent.unspents = params.unspents; diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index a63b7cd791..2e6a21295b 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -3,6 +3,7 @@ */ import should = require('should'); +import nacl from 'tweetnacl'; import { randomBytes } from 'crypto'; import * as sinon from 'sinon'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; @@ -26,7 +27,8 @@ import { Ada, KeyPair, Tada } from '../../src'; import { Transaction } from '../../src/lib'; import { TransactionType } from '../../../sdk-core/src/account-lib/baseCoin/enum'; import assert from 'assert'; -import { common, Wallet } from '@bitgo/sdk-core'; +import { common, EDDSAMethods, Wallet } from '@bitgo/sdk-core'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; import nock from 'nock'; describe('ADA', function () { @@ -804,6 +806,191 @@ describe('ADA', function () { }); }); + describe('Recover Transactions (MPCv2):', () => { + const destAddr = address.address2; + const sandBox = sinon.createSandbox(); + const walletPassphrase = wrwUser.walletPassphrase; + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2WalletAddress: string; + let mismatchedBitgoKey: string; + let mismatchedWalletAddress: string; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [otherUserDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + + mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + mismatchedBitgoKey = otherUserDkg.getCommonKeychain(); + + mpcV2WalletAddress = (await basecoin.getAdaAddressAndAccountId({ bitgoKey: mpcV2CommonKeyChain, index: 0 })) + .address; + mismatchedWalletAddress = (await basecoin.getAdaAddressAndAccountId({ bitgoKey: mismatchedBitgoKey, index: 0 })) + .address; + }); + + let callBack: sinon.SinonStub; + + beforeEach(function () { + callBack = sandBox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack + .withArgs('address_info', { _addresses: [mpcV2WalletAddress] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + callBack + .withArgs('address_info', { _addresses: [mismatchedWalletAddress] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + callBack.withArgs('tip').resolves(endpointResponses.tipInfoResponse); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should route to MPCv2 path for native ADA recovery when keycard is MPCv2', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination: destAddr, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('serializedTx'); + sandBox.assert.notCalled(getTSSSignatureSpy); + + const tx = new Transaction(basecoin); + tx.fromRawTransaction(res.serializedTx); + const txJson = tx.toJson(); + should.deepEqual(txJson.outputs[0].address, destAddr); + }); + + it('should throw when MPCv2 commonKeyChain does not match bitgoKey', async function () { + await basecoin + .recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mismatchedBitgoKey, + walletPassphrase, + recoveryDestination: destAddr, + }) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + + it('should call getEddsaSigningMaterial exactly once per recover() call', async function () { + const getEddsaMaterialSpy = sandBox.spy( + basecoin as unknown as { getEddsaSigningMaterial: unknown }, + 'getEddsaSigningMaterial' + ); + + const res = await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination: destAddr, + }); + + res.should.not.be.empty(); + sandBox.assert.calledOnce(getEddsaMaterialSpy); + }); + + it('should route to MPCv1 path when keycard is MPCv1 (regression)', async function () { + callBack + .withArgs('address_info', { _addresses: [wrwUser.walletAddress0] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = await basecoin.recover({ + userKey: wrwUser.userKey, + backupKey: wrwUser.backupKey, + bitgoKey: wrwUser.bitgoKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: destAddr, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('serializedTx'); + sandBox.assert.calledOnce(getTSSSignatureSpy); + }); + + it('should produce a cryptographically valid Ed25519 signature', async function () { + const signRecoverySpy = sandBox.spy( + basecoin as unknown as { signAdaMpcV2Recovery: unknown }, + 'signAdaMpcV2Recovery' + ); + + await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination: destAddr, + }); + + const rawSig: Buffer = await (signRecoverySpy.firstCall.returnValue as Promise); + const signablePayload: Buffer = (signRecoverySpy.firstCall.args[0] as { message: Buffer }).message; + + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + const accountId = MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + + const isValid = nacl.sign.detached.verify( + new Uint8Array(signablePayload), + new Uint8Array(rawSig), + new Uint8Array(Buffer.from(accountId, 'hex')) + ); + isValid.should.be.true(); + }); + + it('should throw missing userKey when backupKey and walletPassphrase are present but userKey is not', async function () { + callBack.resolves(endpointResponses.addressInfoResponse.OneUTXO); + await basecoin + .recover({ + userKey: undefined, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination: destAddr, + }) + .should.be.rejectedWith('missing userKey'); + }); + + it('should throw missing backupKey when userKey and walletPassphrase are present but backupKey is not', async function () { + callBack.resolves(endpointResponses.addressInfoResponse.OneUTXO); + await basecoin + .recover({ + userKey: mpcV2UserKey, + backupKey: undefined, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination: destAddr, + }) + .should.be.rejectedWith('missing backupKey'); + }); + + it('should throw a clear error when the MPCv1 backup keycard fails to decrypt', async function () { + callBack + .withArgs('address_info', { _addresses: [wrwUser.walletAddress0] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + + await basecoin + .recover({ + userKey: wrwUser.userKey, + backupKey: 'not-a-valid-encrypted-keycard', + bitgoKey: wrwUser.bitgoKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: destAddr, + }) + .should.be.rejectedWith('Error decrypting backup keychain: invalid password or corrupted key'); + }); + }); + describe('Recover Transactions Multiple UTXO:', () => { const destAddr = address.address2; const sandBox = sinon.createSandbox(); @@ -1191,6 +1378,141 @@ describe('ADA', function () { }); }); + describe('Build Consolidation Recoveries (MPCv2):', () => { + const sandBox = sinon.createSandbox(); + const walletPassphrase = wrwUser.walletPassphrase; + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let baseAddr: string; + let mpcV2Address1: string; + let mpcV2Address2: string; + let mpcV2Address3: string; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + + mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + + baseAddr = (await basecoin.getAdaAddressAndAccountId({ bitgoKey: mpcV2CommonKeyChain, index: 0 })).address; + mpcV2Address1 = (await basecoin.getAdaAddressAndAccountId({ bitgoKey: mpcV2CommonKeyChain, index: 1 })).address; + mpcV2Address2 = (await basecoin.getAdaAddressAndAccountId({ bitgoKey: mpcV2CommonKeyChain, index: 2 })).address; + mpcV2Address3 = (await basecoin.getAdaAddressAndAccountId({ bitgoKey: mpcV2CommonKeyChain, index: 3 })).address; + }); + + beforeEach(function () { + const callBack = sandBox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack + .withArgs('address_info', { _addresses: [mpcV2Address1] }) + .resolves(endpointResponses.addressInfoResponse.ZeroUTXO); + callBack + .withArgs('address_info', { _addresses: [mpcV2Address2] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + callBack + .withArgs('address_info', { _addresses: [mpcV2Address3] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO2); + callBack.withArgs('tip').resolves(endpointResponses.tipInfoResponse); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should build MPCv2 signed consolidation recoveries across 2+ funded indexes, sweeping to the MPCv2 base address', async function () { + const getEddsaMaterialSpy = sandBox.spy( + basecoin as unknown as { getEddsaSigningMaterial: unknown }, + 'getEddsaSigningMaterial' + ); + + const res = await basecoin.recoverConsolidations({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + startingScanIndex: 1, + endingScanIndex: 4, + }); + + res.should.not.be.empty(); + res.transactions.length.should.equal(2); + res.lastScanIndex.should.equal(3); + + const tx1 = new Transaction(basecoin); + tx1.fromRawTransaction(res.transactions[0].serializedTx); + should.deepEqual(tx1.toJson().outputs[0].address, baseAddr); + + // recoverConsolidations must detect signing material exactly once at the top of the scan + // loop, not per-iteration inside recover() — precomputedMaterial bypasses per-call detection. + sandBox.assert.calledOnce(getEddsaMaterialSpy); + }); + + it('should leave MPCv1 base address derivation and signing unchanged (regression)', async function () { + sandBox.restore(); + const callBack = sandBox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack + .withArgs('address_info', { _addresses: [consolidationWrwUser.walletAddress1] }) + .resolves(endpointResponses.addressInfoResponse.ZeroUTXO); + callBack + .withArgs('address_info', { _addresses: [consolidationWrwUser.walletAddress2] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO); + callBack + .withArgs('address_info', { _addresses: [consolidationWrwUser.walletAddress3] }) + .resolves(endpointResponses.addressInfoResponse.OneUTXO2); + callBack.withArgs('tip').resolves(endpointResponses.tipInfoResponse); + + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = await basecoin.recoverConsolidations({ + userKey: consolidationWrwUser.userKey, + backupKey: consolidationWrwUser.backupKey, + bitgoKey: consolidationWrwUser.bitgoKey, + walletPassphrase: consolidationWrwUser.walletPassphrase, + startingScanIndex: 1, + endingScanIndex: 4, + }); + + res.should.not.be.empty(); + sandBox.assert.called(getTSSSignatureSpy); + + const tx1 = new Transaction(basecoin); + tx1.fromRawTransaction(res.transactions[0].serializedTx); + should.deepEqual(tx1.toJson().outputs[0].address, consolidationWrwUser.walletAddress0); + }); + + it('should leave the unsigned (no passphrase) cold path unchanged when keycard is MPCv2', async function () { + const res = await basecoin.recoverConsolidations({ + bitgoKey: mpcV2CommonKeyChain, + startingScanIndex: 1, + endingScanIndex: 4, + }); + + res.should.not.be.empty(); + res.txRequests.length.should.equal(2); + }); + + it('should throw missing userKey before scanning when walletPassphrase is set but userKey is omitted', async function () { + const getEddsaMaterialSpy = sandBox.spy( + basecoin as unknown as { getEddsaSigningMaterial: unknown }, + 'getEddsaSigningMaterial' + ); + + await basecoin + .recoverConsolidations({ + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + startingScanIndex: 1, + endingScanIndex: 4, + }) + .should.be.rejectedWith('missing userKey'); + + sandBox.assert.notCalled(getEddsaMaterialSpy); + sandBox.assert.notCalled(basecoin.getDataFromNode as sinon.SinonStub); + }); + }); + describe('Recover Transactions Failure:', () => { const destAddr = address.address2; const sandBox = sinon.createSandbox();