diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index 00d5554666..cd4c25b9ed 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import crypto from 'crypto'; import { BaseBroadcastTransactionOptions, @@ -5,9 +6,12 @@ import { BaseCoin, BaseTransaction, BitGoBase, + decryptKeychainPrivateKey, EDDSAMethods, EDDSAMethodTypes, + EddsaSigningMaterial, Environments, + getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, KeyPair, MPCAlgorithm, MPCRecoveryOptions, @@ -20,6 +24,7 @@ import { ParsedTransaction, ParseTransactionOptions as BaseParseTransactionOptions, RecoveryTxRequest, + signEddsaMpcV2RecoveryTx, SignedTransaction, SignTransactionOptions, TransactionExplanation, @@ -630,6 +635,22 @@ export class Sui extends BaseCoin { return { txRequests: [txRequest] }; } + /** + * Detects MPCv1 vs MPCv2 keycard format and returns typed signing material. + * Wrapped as a protected method so sinon can stub it in tests. + */ + protected async getEddsaSigningMaterial(userKey: string, passphrase: string): Promise { + return sharedGetEddsaSigningMaterial(userKey, passphrase, this.bitgo); + } + + /** + * Runs the MPCv2 (MPS) recovery signing flow and returns the raw 64-byte Ed25519 signature. + * Wrapped as a protected method so sinon can stub it in tests. + */ + protected async signSuiMpcV2Recovery(params: Parameters[0]): Promise { + return signEddsaMpcV2RecoveryTx(params); + } + private async signRecoveryTransaction( txBuilder: TransactionBuilder, params: MPCRecoveryOptions, @@ -641,44 +662,37 @@ export class Sui extends BaseCoin { const unsignedTx = isTokenTransaction ? ((await txBuilder.build()) as TokenTransferTransaction) : ((await txBuilder.build()) as TransferTransaction); - 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, ''); + const bitgoKey = params.bitgoKey.replace(/\s/g, ''); - // Decrypt private keys from KeyCard values - let userPrv: string; - try { - userPrv = await this.bitgo.decrypt({ - input: userKey, - password: params.walletPassphrase, + const signingMaterial = await this.getEddsaSigningMaterial(userKey, params.walletPassphrase); + + if (signingMaterial.version === 'v2') { + const signature = await this.signSuiMpcV2Recovery({ + message: unsignedTx.signablePayload, + userKey: signingMaterial.encryptedUserKey, + backupKey, + walletPassphrase: params.walletPassphrase, + bitgoKey, + derivationPath, + bitgo: this.bitgo, }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e.message}`); + txBuilder.addSignature({ pub: derivedPublicKey }, signature); + return; } + /** TODO BG-52419 Implement Codec for parsing */ - const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; + const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial; - let backupPrv: string; - try { - backupPrv = await this.bitgo.decrypt({ - input: backupKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); - } + 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; - /* ********************** END ***********************************/ // add signature const signatureHex = await EDDSAMethods.getTSSSignature( diff --git a/modules/sdk-coin-sui/test/unit/sui.ts b/modules/sdk-coin-sui/test/unit/sui.ts index 8917af8a2e..ddd55d4d28 100644 --- a/modules/sdk-coin-sui/test/unit/sui.ts +++ b/modules/sdk-coin-sui/test/unit/sui.ts @@ -1,7 +1,8 @@ import should from 'should'; +import nacl from 'tweetnacl'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; +import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { Sui, TokenTransferTransaction, TransferTransaction, Tsui } from '../../src'; import * as testData from '../resources/sui'; import _ from 'lodash'; @@ -12,8 +13,10 @@ import { SuiTransactionType } from '../../src/lib/iface'; import { getBuilderFactory } from './getBuilderFactory'; import { keys } from '../resources/sui'; import { Buffer } from 'buffer'; -import { common, TransactionPrebuild, Wallet } from '@bitgo/sdk-core'; +import { common, EDDSAMethods, MPCRecoveryOptions, MPCTxs, TransactionPrebuild, Wallet } from '@bitgo/sdk-core'; import nock from 'nock'; +import utils from '../../src/lib/utils'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; describe('SUI:', function () { let bitgo: TestBitGoAPI; @@ -726,6 +729,157 @@ describe('SUI:', function () { sandBox.assert.callCount(basecoin.getInputCoins, 1); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); + + describe('MPCv2 signed recovery', function () { + const walletPassphrase = 'test-passphrase-mpcv2'; + + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2WalletAddress: 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')); + + const mpc = await EDDSAMethods.getInitializedMpcInstance(); + const accountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + mpcV2WalletAddress = utils.getAddressFromPublicKey(accountId); + + mpcV2RecoverParams = { + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination, + walletPassphrase, + }; + }); + + beforeEach(() => { + // Fall back to a default balance for any derived address (e.g. when the test + // corrupts bitgoKey, the derived sender address won't match mpcV2WalletAddress). + getBalanceStub.resolves({ + totalBalance: '1900000000', + coinObjectBalance: '1900000000', + fundsInAddressBalance: '0', + }); + getInputCoinsStub.resolves([ + { + coinType: '0x2::sui::SUI', + objectId: '0xc05c765e26e6ae84c78fa245f38a23fb20406a5cf3f61b57bd323a0df9d98003', + version: '195', + digest: '7BJLb32LKN7wt5uv4xgXW4AbFKoMNcPE76o41TQEvUZb', + balance: new BigNumber('1900000000'), + }, + ]); + getFeeEstimateStub.resolves(new BigNumber(1997880)); + }); + + it('should recover a txn using MPCv2 signing material without calling getTSSSignature', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = (await basecoin.recover(mpcV2RecoverParams)) as MPCTxs; + + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + should.equal(tx.scanIndex, 0); + (tx.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandBox.assert.calledWith(getBalanceStub, mpcV2WalletAddress); + sandBox.assert.notCalled(getTSSSignatureSpy); + + // The SUI signature envelope is 1 (flag) + 64 (signature) + 32 (pubkey) bytes. + const signedTx = new TransferTransaction(basecoin); + signedTx.fromRawTransaction(tx.serializedTx as string); + Buffer.from(tx.signature as string, 'base64').length.should.equal(97); + }); + + it('should use the MPCv1 path when signing material is MPCv1 format', async function () { + const getEddsaSigningMaterialStub = sandBox.stub( + Sui.prototype as unknown as { getEddsaSigningMaterial: unknown }, + 'getEddsaSigningMaterial' + ); + getEddsaSigningMaterialStub.resolves({ + version: 'v1', + userPrv: JSON.stringify({ dummy: 'userSigningMaterial' }), + }); + sandBox.stub(bitgo, 'decrypt').resolves(JSON.stringify({ dummy: 'backupSigningMaterial' })); + const getTSSSignatureStub = sandBox + .stub(EDDSAMethods, 'getTSSSignature') + .resolves( + Buffer.from( + '1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' + + 'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857', + 'hex' + ) + ); + + const res = (await basecoin.recover(mpcV2RecoverParams)) as MPCTxs; + + res.should.not.be.empty(); + const tx = res.transactions[0]; + should.equal(tx.scanIndex, 0); + (tx.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandBox.assert.calledOnce(getTSSSignatureStub); + }); + + 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'); + }); + + it('should produce a cryptographically valid Ed25519 signature', async function () { + const signRecoverySpy = sandBox.spy( + basecoin as unknown as { signSuiMpcV2Recovery: unknown }, + 'signSuiMpcV2Recovery' + ); + + const res = (await basecoin.recover(mpcV2RecoverParams)) as MPCTxs; + const tx = res.transactions[0]; + + // SUI envelope: 0x00 flag (1) + raw Ed25519 sig (64) + pubkey (32) = 97 bytes. + const envelope = Buffer.from(tx.signature as string, 'base64'); + envelope.length.should.equal(97); + const rawSig = envelope.slice(1, 65); + + const signablePayload: Buffer = (signRecoverySpy.firstCall.args[0] as { message: Buffer }).message; + + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + const accountId = MPC.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + + const isValid = nacl.sign.detached.verify( + new Uint8Array(signablePayload), + new Uint8Array(rawSig), + new Uint8Array(Buffer.from(accountId, 'hex')) + ); + isValid.should.be.true(); + }); + + it('should throw missing userKey when backupKey and walletPassphrase are present but userKey is not', async function () { + await basecoin.recover({ ...mpcV2RecoverParams, userKey: undefined }).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({ ...mpcV2RecoverParams, backupKey: undefined }) + .should.be.rejectedWith('missing backupKey'); + }); + + it('should return unsigned sweep when walletPassphrase is absent', async function () { + const res = await basecoin.recover({ ...mpcV2RecoverParams, walletPassphrase: undefined }); + (res as any).txRequests.should.not.be.undefined(); + }); + }); }); describe('Recover Token Transactions:', () => { @@ -1036,6 +1190,74 @@ describe('SUI:', function () { sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); + + describe('MPCv2 signed recovery', function () { + const mpcV2WalletPassphrase = 'test-passphrase-mpcv2-token'; + + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2WalletAddress: string; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + mpcV2UserKey = await encrypt(mpcV2WalletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(mpcV2WalletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + + const mpc = await EDDSAMethods.getInitializedMpcInstance(); + const accountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + mpcV2WalletAddress = utils.getAddressFromPublicKey(accountId); + }); + + it('should recover a token txn using MPCv2 signing material without calling getTSSSignature', async function () { + getBalanceStub + .withArgs(mpcV2WalletAddress) + .resolves({ totalBalance: '1900000000', coinObjectBalance: '1900000000', fundsInAddressBalance: '0' }) + .withArgs(mpcV2WalletAddress, coinType) + .resolves({ totalBalance: '1000', coinObjectBalance: '1000', fundsInAddressBalance: '0' }); + getInputCoinsStub.withArgs(mpcV2WalletAddress, coinType).resolves([ + { + coinType: '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP', + objectId: '0x924ab69ebba304f2975a588372b41e4e1f5db7fa824868f84199eeb1e0a15a2d', + version: '34696807', + digest: '7XRbWQTiwAUCjLLsZVpJMrABCheJBkzKVfCr7aTZZVkd', + balance: new BigNumber(1000), + }, + ]); + getInputCoinsStub.withArgs(mpcV2WalletAddress).resolves([ + { + coinType: '0x2::sui::SUI', + objectId: '0x9146928f557cb8ab1915a5886c1362435a05b4709b586bb01d4c70e85bb53161', + version: '239', + digest: 'GLSzR6HJ319nPKAFm5x3TWHcaHZzCFSBCqhvZ1qwT5wr', + balance: new BigNumber('1230261076'), + }, + ]); + getFeeEstimateStub.resolves(new BigNumber(2345504)); + + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = (await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination, + walletPassphrase: mpcV2WalletPassphrase, + tokenContractAddress, + })) as MPCTxs; + + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + should.equal(tx.scanIndex, 0); + (tx.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandBox.assert.notCalled(getTSSSignatureSpy); + + // The SUI signature envelope is 1 (flag) + 64 (signature) + 32 (pubkey) bytes. + Buffer.from(tx.signature as string, 'base64').length.should.equal(97); + }); + }); }); describe('Recover Transactions for wallet with multiple addresses:', () => {