diff --git a/modules/sdk-coin-iota/src/iota.ts b/modules/sdk-coin-iota/src/iota.ts index 2c19465caa..f8fe6ff7df 100644 --- a/modules/sdk-coin-iota/src/iota.ts +++ b/modules/sdk-coin-iota/src/iota.ts @@ -4,6 +4,7 @@ import { BitGoBase, EDDSAMethods, EDDSAMethodTypes, + EDDSAUtils, Environments, KeyPair, MPCAlgorithm, @@ -303,6 +304,21 @@ export class Iota extends BaseCoin { const bitgoKey = params.bitgoKey.replace(/\s/g, ''); const MPC = await EDDSAMethods.getInitializedMpcInstance(); + // Detect MPCv2 keycard format once up front. Unsigned sweeps have no keycard to + // inspect and default to MPCv1 (their signing path is unaffected either way). + let isMpcV2 = false; + if (params.walletPassphrase) { + if (!params.userKey) { + throw new Error('missing userKey'); + } + const isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial( + params.userKey.replace(/\s/g, ''), + params.walletPassphrase, + this.bitgo + ); + isMpcV2 = !isV1; + } + for (let idx = startIdx; idx < endIdx; idx++) { const derivationPath = (params.seed ? getDerivationPath(params.seed) : 'm') + `/${idx}`; const derivedPublicKey = MPC.deriveUnhardened(bitgoKey, derivationPath).slice(0, 64); @@ -337,7 +353,8 @@ export class Iota extends BaseCoin { derivationPath, derivedPublicKey, idx, - bitgoKey + bitgoKey, + isMpcV2 ); } catch (e) { continue; @@ -398,7 +415,9 @@ export class Iota extends BaseCoin { params, derivationPath, derivedPublicKey, - unsignedTx + unsignedTx, + isMpcV2, + bitgoKey ); // Build and return signed transaction @@ -706,7 +725,8 @@ export class Iota extends BaseCoin { derivationPath: string, derivedPublicKey: string, idx: number, - bitgoKey: string + bitgoKey: string, + isMpcV2: boolean ): Promise { tokenObjectsWithBalance = tokenObjectsWithBalance.sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); if (tokenObjectsWithBalance.length > MAX_OBJECT_LIMIT) { @@ -780,7 +800,9 @@ export class Iota extends BaseCoin { params, derivationPath, derivedPublicKey, - unsignedTx + unsignedTx, + isMpcV2, + bitgoKey ); const finalTx = (await txBuilder.build()) as TransferTransaction; @@ -805,7 +827,9 @@ export class Iota extends BaseCoin { params: IotaRecoveryOptions, derivationPath: string, derivedPublicKey: string, - unsignedTx: TransferTransaction + unsignedTx: TransferTransaction, + isMpcV2: boolean, + bitgoKey: string ): Promise { if (!params.userKey) { throw new Error('missing userKey'); @@ -820,30 +844,54 @@ export class Iota extends BaseCoin { const userKey = params.userKey.replace(/\s/g, ''); const backupKey = params.backupKey.replace(/\s/g, ''); - // Decrypt private keys from KeyCard values - let userPrv: string; - try { - userPrv = await this.bitgo.decrypt({ input: userKey, password: params.walletPassphrase }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${(e as Error).message}`); - } - const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; + let signatureBuffer: Buffer; - let backupPrv: string; - try { - backupPrv = await this.bitgo.decrypt({ input: backupKey, password: params.walletPassphrase }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${(e as Error).message}`); - } - const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + if (!isMpcV2) { + // Decrypt private keys from KeyCard values + let userPrv: string; + try { + userPrv = await this.bitgo.decrypt({ input: userKey, password: params.walletPassphrase }); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${(e as Error).message}`); + } + const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; - // Generate TSS signature - const signatureBuffer = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, - derivationPath, - unsignedTx - ); + let backupPrv: string; + try { + backupPrv = await this.bitgo.decrypt({ input: backupKey, password: params.walletPassphrase }); + } catch (e) { + throw new Error(`Error decrypting backup keychain: ${(e as Error).message}`); + } + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + + // Generate TSS signature + signatureBuffer = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + derivationPath, + unsignedTx + ); + } else { + const { userKeyShare, backupKeyShare, commonKeyChain } = + await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + userKey, + backupKey, + params.walletPassphrase, + this.bitgo + ); + + if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) { + throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + } + + signatureBuffer = await EDDSAUtils.signRecoveryEddsaMPCv2( + unsignedTx.signablePayload, + derivationPath, + userKeyShare, + backupKeyShare, + commonKeyChain + ); + } // Build full signature: scheme_flag (1 byte) + signature (64 bytes) + public_key (32 bytes) const schemeFlag = Buffer.alloc(1, 0x00); // Ed25519 scheme diff --git a/modules/sdk-coin-iota/test/unit/iota.ts b/modules/sdk-coin-iota/test/unit/iota.ts index 307c0a997c..1477c60d48 100644 --- a/modules/sdk-coin-iota/test/unit/iota.ts +++ b/modules/sdk-coin-iota/test/unit/iota.ts @@ -1,14 +1,16 @@ import should from 'should'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; +import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { Iota, TransactionBuilderFactory, TransferTransaction } from '../../src'; import assert from 'assert'; import { coins, GasTankAccountCoin } from '@bitgo/statics'; import * as testData from '../resources/iota'; -import { TransactionType } from '@bitgo/sdk-core'; +import { EDDSAMethods, TransactionType } from '@bitgo/sdk-core'; import { createTransferBuilderWithGas } from './helpers/testHelpers'; import sinon from 'sinon'; import { keys } from '../resources/iota'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; +import utils from '../../src/lib/utils'; describe('IOTA:', function () { let bitgo: TestBitGoAPI; @@ -673,6 +675,177 @@ describe('IOTA:', function () { }); }); + describe('Recover Transactions (MPCv2):', () => { + const sandBox = sinon.createSandbox(); + const recoveryDestination = '0xda97e166d40fa6a0c949b6aeb862e391c29139b563ae0430b2419c589a02a6e0'; + const walletPassphrase = 'p$Sw { + sandBox.restore(); + }); + + it('should route to MPCv2 path for native IOTA recovery when keycard is MPCv2', async function () { + sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([ + { + objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003', + version: '195', + digest: validDigest, + balance: '1900000000', + }, + ]); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880); + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination, + walletPassphrase, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + tx.scanIndex.should.equal(0); + tx.recoveryAmount.should.equal('1897802332'); + + const sigBuffer = Buffer.from(tx.signature, 'base64'); + sigBuffer.length.should.equal(97); // 1 flag byte + 64-byte signature + 32-byte public key + sigBuffer[0].should.equal(0x00); + + sandBox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should throw when MPCv2 commonKeyChain does not match bitgoKey', async function () { + sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([ + { + objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003', + version: '195', + digest: validDigest, + balance: '1900000000', + }, + ]); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880); + + await basecoin + .recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mismatchedBitgoKey, + recoveryDestination, + walletPassphrase, + }) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + + it('should route to MPCv2 path for token recovery when keycard is MPCv2', async function () { + sandBox.stub(Iota.prototype, 'hasTokenBalance' as keyof Iota).callsFake(function (addr: string) { + return Promise.resolve(addr === mpcV2SenderAddress); + }); + sandBox + .stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota) + .callsFake(function (addr: string, _rpc: unknown, coinType: string) { + if (addr === mpcV2SenderAddress && coinType === tokenContractAddress) { + return Promise.resolve([ + { + objectId: '0xaaaa' + mpcV2SenderAddress.slice(6), + version: '100', + digest: validDigest, + balance: '1000', + }, + ]); + } + if (addr === mpcV2SenderAddress && !coinType) { + return Promise.resolve([ + { + objectId: '0xbbbb' + mpcV2SenderAddress.slice(6), + version: '200', + digest: validDigest, + balance: '500000000', + }, + ]); + } + return Promise.resolve([]); + }); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(2345504); + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination, + walletPassphrase, + tokenContractAddress, + }); + + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + tx.scanIndex.should.equal(0); + tx.recoveryAmount.should.equal('1000'); + tx.coin.should.equal(tokenContractAddress); + + const sigBuffer = Buffer.from(tx.signature, 'base64'); + sigBuffer.length.should.equal(97); + sigBuffer[0].should.equal(0x00); + + sandBox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should still use the MPCv1 signing path when the keycard is MPCv1 (regression)', async function () { + sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([ + { + objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003', + version: '195', + digest: validDigest, + balance: '1900000000', + }, + ]); + sandBox.stub(Iota.prototype, 'fetchGasPrice' as keyof Iota).resolves(1000); + sandBox.stub(Iota.prototype, 'estimateGas' as keyof Iota).resolves(1997880); + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = await basecoin.recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + recoveryDestination, + walletPassphrase: 'p$Sw { const sandBox = sinon.createSandbox(); const senderAddress0 = '0xfd36d2ad48edf5671abf04f5c0eef3464bf92cf45ae655aff471cfaedb61fa99';