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
70 changes: 42 additions & 28 deletions modules/sdk-coin-sui/src/sui.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import assert from 'assert';
import crypto from 'crypto';
import {
BaseBroadcastTransactionOptions,
BaseBroadcastTransactionResult,
BaseCoin,
BaseTransaction,
BitGoBase,
decryptKeychainPrivateKey,
EDDSAMethods,
EDDSAMethodTypes,
EddsaSigningMaterial,
Environments,
getEddsaSigningMaterial as sharedGetEddsaSigningMaterial,
KeyPair,
MPCAlgorithm,
MPCRecoveryOptions,
Expand All @@ -20,6 +24,7 @@ import {
ParsedTransaction,
ParseTransactionOptions as BaseParseTransactionOptions,
RecoveryTxRequest,
signEddsaMpcV2RecoveryTx,
SignedTransaction,
SignTransactionOptions,
TransactionExplanation,
Expand Down Expand Up @@ -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<EddsaSigningMaterial> {
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<typeof signEddsaMpcV2RecoveryTx>[0]): Promise<Buffer> {
return signEddsaMpcV2RecoveryTx(params);
}

private async signRecoveryTransaction(
txBuilder: TransactionBuilder,
params: MPCRecoveryOptions,
Expand All @@ -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(
Expand Down
226 changes: 224 additions & 2 deletions modules/sdk-coin-sui/test/unit/sui.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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:', () => {
Expand Down Expand Up @@ -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:', () => {
Expand Down
Loading