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
10 changes: 10 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ export interface VerifyAddressOptions {
* For SMC (Self-Managed Custodial) TSS wallets, this is used to compute the derivation prefix.
*/
derivedFromParentWithSeed?: string;
/**
* Identifies the MPC signing protocol version of the wallet (e.g. 'MPCv2').
* Used to distinguish between MPCv1 and MPCv2 wallets.
*/
multisigTypeVersion?: 'MPCv2';
}

/**
Expand Down Expand Up @@ -187,6 +192,11 @@ export interface TssVerifyAddressOptions {
* The derivation path becomes {computedPrefix}/{index} instead of m/{index}.
*/
derivedFromParentWithSeed?: string;
/**
* Identifies the MPC signing protocol version of the wallet (e.g. 'MPCv2').
* Used to distinguish between MPCv1 and MPCv2 wallets.
*/
multisigTypeVersion?: 'MPCv2';
}

export function isTssVerifyAddressOptions<T extends VerifyAddressOptions | TssVerifyAddressOptions>(
Expand Down
13 changes: 10 additions & 3 deletions modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { getDerivationPath, deriveUnhardenedMps } from '@bitgo/sdk-lib-mpc';
import { Ecdsa } from '../../../account-lib/mpc';
import { TssVerifyAddressOptions } from '../../baseCoin/iBaseCoin';
import { InvalidAddressError } from '../../errors';
Expand Down Expand Up @@ -72,15 +72,22 @@ export async function verifyMPCWalletAddress(
throw new InvalidAddressError(`invalid address: ${address}`);
}

const MPC = params.keyCurve === 'secp256k1' ? new Ecdsa() : await EDDSAMethods.getInitializedMpcInstance();
const commonKeychain = extractCommonKeychain(keychains);

// Compute derivation path:
// - For SMC wallets with derivedFromParentWithSeed, compute prefix and use: {prefix}/{index}
// - For other wallets, use simple path: m/{index}
const prefix = derivedFromParentWithSeed ? getDerivationPath(derivedFromParentWithSeed.toString()) : undefined;
const derivationPath = prefix ? `${prefix}/${index}` : `m/${index}`;
const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath);

// MPCv2 EdDSA wallets use a different BIP32-Ed25519 derivation formula than MPCv1 wallets.
let derivedPublicKey: string;
if (params.keyCurve === 'ed25519' && params.multisigTypeVersion === 'MPCv2') {
derivedPublicKey = deriveUnhardenedMps(commonKeychain, derivationPath);
} else {
const MPC = params.keyCurve === 'secp256k1' ? new Ecdsa() : await EDDSAMethods.getInitializedMpcInstance();
derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath);
}

// secp256k1 expects 33 bytes; ed25519 expects 32 bytes
const publicKeySize = params.keyCurve === 'secp256k1' ? 33 : 32;
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,7 @@ export class Wallet implements IWallet {
const verificationData: VerifyAddressOptions = _.merge({}, newAddress, {
rootAddress,
walletVersion: _.get(this._wallet, 'coinSpecific.walletVersion'),
multisigTypeVersion: this.multisigTypeVersion(),
});

if (verificationData.error) {
Expand Down
185 changes: 184 additions & 1 deletion modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import * as assert from 'assert';
import 'should';
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { deriveUnhardenedMps, getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { Ecdsa } from '../../../../../src/account-lib/mpc';

function getAddressVerificationModule() {
return require('../../../../../src/bitgo/utils/tss/addressVerification');
}

const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain;
const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress;
const getVerifyMPCWalletAddress = () => getAddressVerificationModule().verifyMPCWalletAddress;

// RFC 8032 test vector: known valid Ed25519 public key + arbitrary chaincode = 128 hex chars.
const TEST_PK = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a';
const TEST_CHAINCODE = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
const TEST_KEYCHAIN = TEST_PK + TEST_CHAINCODE;

// secp256k1 generator point G (compressed, 33 bytes = 66 hex) + same chaincode = 130 hex chars.
const ECDSA_PK = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
const ECDSA_KEYCHAIN = ECDSA_PK + TEST_CHAINCODE;

describe('TSS Address Verification - Derivation Path with Prefix', function () {
const commonKeychain =
Expand Down Expand Up @@ -61,3 +73,174 @@ describe('TSS Address Verification - Derivation Path with Prefix', function () {
});
});
});

describe('verifyEddsaTssWalletAddress', function () {
const keychains = [
{ commonKeychain: TEST_KEYCHAIN },
{ commonKeychain: TEST_KEYCHAIN },
{ commonKeychain: TEST_KEYCHAIN },
];
const isValidAddress = (addr: string) => addr.length === 64;
const getAddressFromPublicKey = (pk: string) => pk;

describe('MPCv2 wallets (Silence Labs / MPS formula)', function () {
it('verifies a correct address derived with deriveUnhardenedMps at index 0', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);

const result = await verifyEddsaTssWalletAddress(
{ address: expectedAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' },
isValidAddress,
getAddressFromPublicKey
);
result.should.be.true();
});

it('verifies a correct address derived with deriveUnhardenedMps at index 1', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/1').slice(0, 64);

const result = await verifyEddsaTssWalletAddress(
{ address: expectedAddress, keychains, index: 1, multisigTypeVersion: 'MPCv2' },
isValidAddress,
getAddressFromPublicKey
);
result.should.be.true();
});

it('rejects an address derived at a different index', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
const addressFromIndex0 = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);

const result = await verifyEddsaTssWalletAddress(
{ address: addressFromIndex0, keychains, index: 1, multisigTypeVersion: 'MPCv2' },
isValidAddress,
getAddressFromPublicKey
);
result.should.be.false();
});

it('rejects a random address that was not derived from the keychain', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
const randomAddress = 'ab'.repeat(32); // 64 hex chars, wrong address

const result = await verifyEddsaTssWalletAddress(
{ address: randomAddress, keychains, index: 0, multisigTypeVersion: 'MPCv2' },
isValidAddress,
getAddressFromPublicKey
);
result.should.be.false();
});

it('verifies a correct MPCv2 address for SMC wallet using derivedFromParentWithSeed', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
const seed = 'smc-seed-123';
const prefix = getDerivationPath(seed);
const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, `${prefix}/0`).slice(0, 64);

const result = await verifyEddsaTssWalletAddress(
{
address: expectedAddress,
keychains,
index: 0,
multisigTypeVersion: 'MPCv2',
derivedFromParentWithSeed: seed,
},
isValidAddress,
getAddressFromPublicKey
);
result.should.be.true();
});

it('rejects an MPCv2 address derived at the wrong index when derivedFromParentWithSeed is set', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
const seed = 'smc-seed-123';
const prefix = getDerivationPath(seed);
const addressAtIndex1 = deriveUnhardenedMps(TEST_KEYCHAIN, `${prefix}/1`).slice(0, 64);

const result = await verifyEddsaTssWalletAddress(
{
address: addressAtIndex1,
keychains,
index: 0,
multisigTypeVersion: 'MPCv2',
derivedFromParentWithSeed: seed,
},
isValidAddress,
getAddressFromPublicKey
);
result.should.be.false();
});
});

describe('non-MPCv2 wallets (MPCv1 formula)', function () {
it('rejects an MPCv2-derived address when multisigTypeVersion is not set', async function () {
const verifyEddsaTssWalletAddress = getVerifyEddsaTssWalletAddress();
// MPCv2 (Silence Labs) and MPCv1 formulas produce different addresses for the same keychain.
// Without multisigTypeVersion: 'MPCv2', the MPCv1 formula is used, so the MPCv2-derived
// address should not match.
const mpcv2Address = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/0').slice(0, 64);

const result = await verifyEddsaTssWalletAddress(
{ address: mpcv2Address, keychains, index: 0 },
isValidAddress,
getAddressFromPublicKey
);

result.should.be.false();
});
});
});

describe('verifyMPCWalletAddress - ECDSA (secp256k1)', function () {
const ecdsaKeychains = [
{ commonKeychain: ECDSA_KEYCHAIN },
{ commonKeychain: ECDSA_KEYCHAIN },
{ commonKeychain: ECDSA_KEYCHAIN },
];
// secp256k1 compressed public key is 33 bytes = 66 hex chars
const isValidEcdsaAddress = (addr: string) => addr.length === 66;
const getAddressFromPublicKey = (pk: string) => pk;

it('ignores multisigTypeVersion MPCv2 and uses Ecdsa derivation for secp256k1 wallets', async function () {
const verifyMPCWalletAddress = getVerifyMPCWalletAddress();
const expectedAddress = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/0').slice(0, 66);

const result = await verifyMPCWalletAddress(
{
address: expectedAddress,
keychains: ecdsaKeychains,
index: 0,
multisigTypeVersion: 'MPCv2',
keyCurve: 'secp256k1',
},
isValidEcdsaAddress,
getAddressFromPublicKey
);
result.should.be.true();
});

it('verifies a correct secp256k1 address at index 1', async function () {
const verifyMPCWalletAddress = getVerifyMPCWalletAddress();
const expectedAddress = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/1').slice(0, 66);

const result = await verifyMPCWalletAddress(
{ address: expectedAddress, keychains: ecdsaKeychains, index: 1, keyCurve: 'secp256k1' },
isValidEcdsaAddress,
getAddressFromPublicKey
);
result.should.be.true();
});

it('rejects an address derived at a different index', async function () {
const verifyMPCWalletAddress = getVerifyMPCWalletAddress();
const addressAtIndex0 = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/0').slice(0, 66);

const result = await verifyMPCWalletAddress(
{ address: addressAtIndex0, keychains: ecdsaKeychains, index: 1, keyCurve: 'secp256k1' },
isValidEcdsaAddress,
getAddressFromPublicKey
);
result.should.be.false();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,62 @@ describe('Wallet - TSS Address Verification with Derivation Prefix', function ()
});
});

describe('MPCv2 Wallet - multisigTypeVersion threading', function () {
beforeEach(function () {
mockWalletData.multisigTypeVersion = 'MPCv2';
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
});

it('should thread multisigTypeVersion MPCv2 into verificationData', async function () {
const mockAddressResponse = {
id: 'address-id',
address: '6FjshVqwmDH74wfxkZrJaRGEjTeJQL4ViL6X18VXUNAY',
index: 0,
coinSpecific: {},
};

mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockAddressResponse),
}),
});

mockBaseCoin.isWalletAddress.resolves(true);

await wallet.createAddress({ chain: 0 });

const verificationCall = mockBaseCoin.isWalletAddress.getCall(0);
const verificationData = verificationCall.args[0];
assert.strictEqual(verificationData.multisigTypeVersion, 'MPCv2');
});

it('should not set multisigTypeVersion when wallet does not have it', async function () {
mockWalletData.multisigTypeVersion = undefined;
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);

const mockAddressResponse = {
id: 'address-id',
address: '6FjshVqwmDH74wfxkZrJaRGEjTeJQL4ViL6X18VXUNAY',
index: 0,
coinSpecific: {},
};

mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockAddressResponse),
}),
});

mockBaseCoin.isWalletAddress.resolves(true);

await wallet.createAddress({ chain: 0 });

const verificationCall = mockBaseCoin.isWalletAddress.getCall(0);
const verificationData = verificationCall.args[0];
assert.strictEqual(verificationData.multisigTypeVersion, undefined);
});
});

describe('Edge Cases', function () {
it('should handle wallet without USER keychain', async function () {
// Set keys array to only have backup keychain (no USER keychain at index 0)
Expand Down
Loading