diff --git a/modules/sdk-coin-dot/src/dot.ts b/modules/sdk-coin-dot/src/dot.ts index 50f084b62c..b4fd41aaf0 100644 --- a/modules/sdk-coin-dot/src/dot.ts +++ b/modules/sdk-coin-dot/src/dot.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import * as _ from 'lodash'; import { BaseCoin, @@ -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 { @@ -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 { @@ -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 { + return sharedGetEddsaSigningMaterial(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo); + } + + protected async signDotMpcV2Recovery(params: Parameters[0]): Promise { + 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 { + 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') { diff --git a/modules/sdk-coin-dot/test/unit/dot.ts b/modules/sdk-coin-dot/test/unit/dot.ts index a41abbc00a..e78d6a21d5 100644 --- a/modules/sdk-coin-dot/test/unit/dot.ts +++ b/modules/sdk-coin-dot/test/unit/dot.ts @@ -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; @@ -438,6 +440,195 @@ 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 throw missing userKey when backupKey and walletPassphrase are present but userKey is not', async function () { + await basecoin + .recover({ + userKey: undefined, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination, + }) + .should.be.rejectedWith('missing userKey'); + }); + + it('should throw missing backupKey when userKey and walletPassphrase are present but backupKey is not', async function () { + await basecoin + .recover({ + userKey: mpcV2UserKey, + backupKey: undefined, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + recoveryDestination, + }) + .should.be.rejectedWith('missing backupKey'); + }); + + it('should throw missing wallet passphrase when userKey and backupKey are present but walletPassphrase is not', async function () { + await basecoin + .recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase: undefined, + recoveryDestination, + }) + .should.be.rejectedWith('missing wallet passphrase'); + }); + + 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;