diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 40f324e713..c874aa2455 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, + decryptKeychainPrivateKey, + getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, + signEddsaMpcV2RecoveryTx, + EddsaSigningMaterial, } from '@bitgo/sdk-core'; import { KeyPair as AdaKeyPair, Transaction, TransactionBuilderFactory, Utils } from './lib'; import type { Asset } from './lib/transaction'; @@ -361,6 +365,22 @@ export class Ada extends BaseCoin { return { transactions: broadcastableTransactions, lastScanIndex }; } + /** + * Detects MPCv1 vs MPCv2 keycard format from the encrypted user key and returns + * typed signing material accordingly. Wrapped as protected so tests can stub it. + */ + protected async getEddsaSigningMaterial(userKey: string, passphrase: string): Promise { + return sharedGetEddsaSigningMaterial(userKey, passphrase, this.bitgo); + } + + /** + * Runs the MPS DSG protocol locally to sign a recovery transaction for MPCv2 keycards. + * Wrapped as protected so tests can stub it. + */ + protected async signAdaMpcV2Recovery(params: Parameters[0]): Promise { + return signEddsaMpcV2RecoveryTx(params); + } + /** * Builds funds recovery transaction(s) without BitGo * @@ -447,53 +467,49 @@ 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, + const signingMaterial = await this.getEddsaSigningMaterial(userKey, params.walletPassphrase); + + let signature: Buffer; + if (signingMaterial.version === 'v2') { + 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 user keychain: ${e.message}`); + } 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 + ); + assert(backupPrv, 'Error decrypting backup keychain: invalid password or corrupted key'); + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + + signature = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + currPath, + unsignedTransaction + ); } - /** 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, - }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); - } - 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); + txBuilder.addSignature({ pub: adaKeyPair.getKeys().pub }, signature); const signedTransaction = await txBuilder.build(); serializedTx = signedTransaction.toBroadcastFormat(); } else { diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index a63b7cd791..82c89b99d0 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -26,7 +26,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, MPCRecoveryOptions, MPCTx, Wallet } from '@bitgo/sdk-core'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; import nock from 'nock'; describe('ADA', function () { @@ -804,6 +805,107 @@ describe('ADA', function () { }); }); + describe('Recover Transactions (MPCv2):', function () { + const walletPassphrase = 'test-passphrase-mpcv2'; + const destAddr = address.address2; + const sandbox = sinon.createSandbox(); + + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2RecoverParams: MPCRecoveryOptions; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + + mpcV2RecoverParams = { + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: destAddr, + walletPassphrase, + }; + }); + + beforeEach(function () { + const callBack = sandbox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack.withArgs('address_info', sinon.match.any).resolves(endpointResponses.addressInfoResponse.OneUTXO); + callBack.withArgs('tip').resolves(endpointResponses.tipInfoResponse); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should recover ADA using MPCv2 signing material', async function () { + const getTSSSignatureSpy = sandbox.spy(EDDSAMethods, 'getTSSSignature'); + + const result = (await basecoin.recover(mpcV2RecoverParams)) as MPCTx; + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + (result.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should use MPCv1 path when signing material is MPCv1 format', async function () { + sandbox.stub(basecoin as unknown as { getEddsaSigningMaterial: unknown }, 'getEddsaSigningMaterial').resolves({ + version: 'v1', + userPrv: JSON.stringify({ uShare: {}, bitgoYShare: {} }), + }); + + const getTSSSignatureStub = sandbox + .stub(EDDSAMethods, 'getTSSSignature') + .resolves( + Buffer.from( + '1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' + + 'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857', + 'hex' + ) + ); + + sandbox.stub(bitgo, 'decrypt').resolves(JSON.stringify({ bShare: {}, yShares: {} })); + + const result = (await basecoin.recover(mpcV2RecoverParams)) as MPCTx; + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + (result.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.calledOnce(getTSSSignatureStub); + }); + + it('should return an unsigned sweep transaction when walletPassphrase is missing', async function () { + const getEddsaSigningMaterialSpy = sandbox.spy( + basecoin as unknown as { getEddsaSigningMaterial: () => unknown }, + 'getEddsaSigningMaterial' + ); + + const result = await basecoin.recover({ + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: destAddr, + }); + + result.should.not.be.empty(); + result.txRequests[0].transactions[0].unsignedTx.should.hasOwnProperty('serializedTx'); + sandbox.assert.notCalled(getEddsaSigningMaterialSpy); + }); + + it('should throw when commonKeyChain from MPCv2 keycard does not match bitgoKey', async function () { + const mismatchedBitgoKey = mpcV2CommonKeyChain.slice(0, -8) + '00000000'; + const mismatchedParams = { + ...mpcV2RecoverParams, + bitgoKey: mismatchedBitgoKey, + }; + + await basecoin + .recover(mismatchedParams) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + }); + describe('Recover Transactions Multiple UTXO:', () => { const destAddr = address.address2; const sandBox = sinon.createSandbox();