Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 54 additions & 38 deletions modules/sdk-coin-ada/src/ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<EddsaSigningMaterial> {
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<typeof signEddsaMpcV2RecoveryTx>[0]): Promise<Buffer> {
return signEddsaMpcV2RecoveryTx(params);
}

/**
* Builds funds recovery transaction(s) without BitGo
*
Expand Down Expand Up @@ -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 {
Expand Down
104 changes: 103 additions & 1 deletion modules/sdk-coin-ada/test/unit/ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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();
Expand Down
Loading