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 2eba5c7906..cb3cac8aa2 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..8f72b179e9 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,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 = @@ -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(); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletTssAddressVerification.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletTssAddressVerification.ts index 6a3368a9fb..62a438eca4 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletTssAddressVerification.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletTssAddressVerification.ts @@ -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)