diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index a9ce31b312..80cf6d3236 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -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'; } /** @@ -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( diff --git a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts index 67e41d1ade..20e1a75332 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts @@ -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'; @@ -72,7 +72,6 @@ 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: @@ -80,7 +79,15 @@ export async function verifyMPCWalletAddress( // - 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; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index d05427cff5..a16b5d0574 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -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) { diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts index c9ce76bc84..e4926e3bf3 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts @@ -1,12 +1,18 @@ import * as assert from 'assert'; import 'should'; -import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; +import { deriveUnhardenedMps, getDerivationPath } from '@bitgo/sdk-lib-mpc'; function getAddressVerificationModule() { return require('../../../../../src/bitgo/utils/tss/addressVerification'); } const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain; +const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress; + +// 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; describe('TSS Address Verification - Derivation Path with Prefix', function () { const commonKeychain = @@ -61,3 +67,81 @@ 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(); + }); + }); + + 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(); + }); + }); +});