Skip to content
Open
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
124 changes: 79 additions & 45 deletions modules/sdk-coin-dot/src/dot.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from 'assert';
import * as _ from 'lodash';
import {
BaseCoin,
Expand Down Expand Up @@ -30,6 +31,10 @@ import {
AuditDecryptedKeyParams,
verifyEddsaTssWalletAddress,
TxIntentMismatchRecipientError,
getEddsaSigningMaterial as sharedGetEddsaSigningMaterial,
signEddsaMpcV2RecoveryTx,
EddsaSigningMaterial,
decryptKeychainPrivateKey,
} from '@bitgo/sdk-core';
import { BaseCoin as StaticsBaseCoin, coins, PolkadotSpecNameType } from '@bitgo/statics';
import {
Expand Down Expand Up @@ -393,53 +398,21 @@ export class Dot 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');
}

// 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,
});
} 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,
assert(params.userKey, 'missing userKey');
assert(params.backupKey, 'missing backupKey');
assert(params.walletPassphrase, 'missing wallet passphrase');

const signingMaterial = await this.getEddsaSigningMaterial(params.userKey, params.walletPassphrase);
await this.addRecoverySignature(
signingMaterial,
params.backupKey.replace(/\s/g, ''),
params.walletPassphrase,
txnBuilder,
accountId,
unsignedTransaction,
currPath,
unsignedTransaction
bitgoKey
);
const dotKeyPair = new DotKeyPair({ pub: accountId });
txnBuilder.addSignature({ pub: dotKeyPair.getKeys().pub }, signatureHex);
const signedTransaction = await txnBuilder.build();
serializedTx = signedTransaction.toBroadcastFormat();
} else {
Expand Down Expand Up @@ -742,6 +715,67 @@ export class Dot extends BaseCoin {
return new TransactionBuilderFactory(coins.get(this.getChain()));
}

/**
* 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<EddsaSigningMaterial> {
return sharedGetEddsaSigningMaterial(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo);
}

protected async signDotMpcV2Recovery(params: Parameters<typeof signEddsaMpcV2RecoveryTx>[0]): Promise<Buffer> {
return signEddsaMpcV2RecoveryTx(params);
}

/**
* Adds an MPCv1 or MPCv2 signature to a DOT transaction builder.
*
* Transaction#constructSignedPayload already prepends the 0x00 type-tag (the Substrate
* MultiSignature enum discriminant for Ed25519) to whatever signature buffer is passed to
* addSignature, so both paths here hand off the raw 64-byte signature untouched.
*/
private async addRecoverySignature(
signingMaterial: EddsaSigningMaterial,
backupKey: string,
walletPassphrase: string,
txnBuilder: NativeTransferBuilder,
accountId: string,
unsignedTransaction: Transaction,
currPath: string,
bitgoKey: string
): Promise<void> {
const dotKeyPair = new DotKeyPair({ pub: accountId });

if (signingMaterial.version === 'v2') {
const rawSig = await this.signDotMpcV2Recovery({
message: unsignedTransaction.signablePayload,
userKey: signingMaterial.encryptedUserKey,
backupKey,
walletPassphrase,
bitgoKey,
derivationPath: currPath,
bitgo: this.bitgo,
});
txnBuilder.addSignature({ pub: dotKeyPair.getKeys().pub }, rawSig);
} 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 }, walletPassphrase);
if (!backupPrv) {
throw new Error('Error decrypting backup keychain: invalid password or corrupted key');
}
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;

const signatureHex = await EDDSAMethods.getTSSSignature(
userSigningMaterial,
backupSigningMaterial,
currPath,
unsignedTransaction
);
txnBuilder.addSignature({ pub: dotKeyPair.getKeys().pub }, signatureHex);
}
}

/** @inheritDoc */
auditDecryptedKey({ publicKey, prv, multiSigType }: AuditDecryptedKeyParams) {
if (multiSigType !== 'tss') {
Expand Down
161 changes: 158 additions & 3 deletions modules/sdk-coin-dot/test/unit/dot.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { BitGoAPI } from '@bitgo/sdk-api';
import { BitGoAPI, encrypt } from '@bitgo/sdk-api';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { randomBytes } from 'crypto';
import should = require('should');
import assert = require('assert');
import nacl from 'tweetnacl';
import { Dot, Tdot, KeyPair } from '../../src';
import * as testData from '../fixtures';
import { chainName, txVersion, genesisHash, specVersion } from '../resources';
import * as sinon from 'sinon';
import { TransactionType, Wallet } from '@bitgo/sdk-core';
import { EDDSAMethods, MPCTx, TransactionType, Wallet } from '@bitgo/sdk-core';
import { coins } from '@bitgo/statics';
import { buildTransaction, type BuildContext, type Material } from '@bitgo/wasm-dot';
import { MPSUtil } from '@bitgo/sdk-lib-mpc';
import utils from '../../src/lib/utils';
import { explainDotTransaction } from '../../src/lib';
import { explainDotTransaction, SingletonRegistry } from '../../src/lib';

describe('DOT:', function () {
let bitgo: TestBitGoAPI;
Expand Down Expand Up @@ -438,6 +440,159 @@ describe('DOT:', function () {
});
});

describe('Recover Transactions (MPCv2):', () => {
const mpcV2SandBox = sinon.createSandbox();
const walletPassphrase = testData.wrwUser.walletPassphrase;
const nonce = 7;
const recoveryDestination = testData.accounts.account1.address;

let mpcV2UserKey: string;
let mpcV2BackupKey: string;
let mpcV2CommonKeyChain: string;
let mpcV2WalletAddress: string;
let mismatchedBitgoKey: 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();

const MPC = await EDDSAMethods.getInitializedMpcInstance();
const accountId = MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64);
mpcV2WalletAddress = basecoin.getAddressFromPublicKey(accountId);
});

beforeEach(function () {
const accountInfoCB = mpcV2SandBox.stub(Dot.prototype, 'getAccountInfo' as keyof Dot);
accountInfoCB.withArgs(mpcV2WalletAddress).resolves({
nonce,
freeBalance: 1510000000000,
});
const headerInfoCB = mpcV2SandBox.stub(Dot.prototype, 'getHeaderInfo' as keyof Dot);
headerInfoCB.resolves({
headerNumber: testData.westendBlock.blockNumber,
headerHash: testData.westendBlock.hash,
});
const getFeeCB = mpcV2SandBox.stub(Dot.prototype, 'getFee' as keyof Dot);
getFeeCB.resolves(15783812856);
const getMaterialCB = mpcV2SandBox.stub(Dot.prototype, 'getMaterial' as keyof Dot);
getMaterialCB.resolves(utils.getMaterial(coins.get('tdot')));
});

afterEach(function () {
mpcV2SandBox.restore();
});

it('should route to MPCv2 path and return a signed recovery transaction', async function () {
const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature');

const result = (await basecoin.recover({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
walletPassphrase,
recoveryDestination,
})) as MPCTx;

result.should.not.be.empty();
result.should.hasOwnProperty('serializedTx');
result.should.hasOwnProperty('scanIndex');
should.equal(result.scanIndex, 0);
mpcV2SandBox.assert.notCalled(getTSSSignatureSpy);
});

it('should encode a valid 64-byte Ed25519 signature with the Substrate 0x00 discriminant', async function () {
const result = (await basecoin.recover({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mpcV2CommonKeyChain,
walletPassphrase,
recoveryDestination,
})) as MPCTx;

const material = utils.getMaterial(coins.get('tdot'));
const registry = SingletonRegistry.getInstance(material);
// recoverSignatureFromRawTx strips exactly one leading 0x00 Ed25519 discriminant byte and
// returns the raw signature bytes. A double-prefix corruption bug (the exact bug found and
// fixed while implementing this ticket) would shift every subsequent field by one byte, but
// this extrinsic type still decodes a fixed 64-byte signature field either way -- a bare
// length check on this value cannot tell a correct signature apart from a corrupted one.
// Verifying against the transaction's actual signable payload and derived pubkey can.
const recoveredSignature = utils.recoverSignatureFromRawTx(result.serializedTx, { registry });
recoveredSignature.length.should.equal(128);

// Rebuild the Transaction object from the signed broadcast hex (mirrors the existing
// "unsigned-sweep recoveries" test above) so we can read back its signablePayload -- the
// exact bytes the MPCv2 DSG protocol signed inside addRecoverySignature.
const txBuilder = basecoin.getBuilder().from(result.serializedTx);
txBuilder
.validity({
firstValid: testData.westendBlock.blockNumber,
maxDuration: basecoin.MAX_VALIDITY_DURATION,
})
.referenceBlock(testData.westendBlock.hash)
.sender({ address: mpcV2WalletAddress });
const tx = await txBuilder.build();

const MPC = await EDDSAMethods.getInitializedMpcInstance();
const accountId = MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64);
const isValid = nacl.sign.detached.verify(
new Uint8Array(tx.signablePayload),
new Uint8Array(Buffer.from(recoveredSignature, 'hex')),
new Uint8Array(Buffer.from(accountId, 'hex'))
);
isValid.should.be.true();
});

it('should throw when MPCv2 keycard commonKeyChain does not match bitgoKey', async function () {
const MPC = await EDDSAMethods.getInitializedMpcInstance();
const accountId = MPC.deriveUnhardened(mismatchedBitgoKey, 'm/0').slice(0, 64);
const mismatchedAddress = basecoin.getAddressFromPublicKey(accountId);
(Dot.prototype as unknown as { getAccountInfo: sinon.SinonStub }).getAccountInfo
.withArgs(mismatchedAddress)
.resolves({
nonce,
freeBalance: 1510000000000,
});

await basecoin
.recover({
userKey: mpcV2UserKey,
backupKey: mpcV2BackupKey,
bitgoKey: mismatchedBitgoKey,
walletPassphrase,
recoveryDestination,
})
.should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey');
});

it('should still use the MPCv1 path (getTSSSignature) for MPCv1 JSON keycards', async function () {
const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature');
(Dot.prototype as unknown as { getAccountInfo: sinon.SinonStub }).getAccountInfo
.withArgs(testData.wrwUser.walletAddress0)
.resolves({
nonce,
freeBalance: 1510000000000,
});

const result = (await basecoin.recover({
userKey: testData.wrwUser.userKey,
backupKey: testData.wrwUser.backupKey,
bitgoKey: testData.wrwUser.bitgoKey,
walletPassphrase: testData.wrwUser.walletPassphrase,
recoveryDestination,
})) as MPCTx;

result.should.not.be.empty();
result.should.hasOwnProperty('serializedTx');
mpcV2SandBox.assert.calledOnce(getTSSSignatureSpy);
});
});

describe('Build Consolidation Recoveries:', () => {
const sandBox = sinon.createSandbox();
const baseAddr = testData.consolidationWrwUser.walletAddress0;
Expand Down
Loading