diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts index 51836c386c1..5b16ebadc65 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts @@ -4,7 +4,14 @@ import * as openpgp from 'openpgp'; import { decode } from 'cbor-x'; import { TestableBG, TestBitGo } from '@bitgo/sdk-test'; -import { AddKeychainOptions, common, ECDSAUtils, Wallet } from '@bitgo/sdk-core'; +import { + AddKeychainOptions, + buildSafeBip44Path, + common, + DerivedFromParentWithHardenedPath, + ECDSAUtils, + Wallet, +} from '@bitgo/sdk-core'; import { DklsComms, DklsDrv, DklsTypes, DklsUtils, DklsVrfUtils } from '@bitgo/sdk-lib-mpc'; import { MPCv2DeriveRound1Request, MPCv2DeriveRound2Request, MPCv2DeriveRound3Request } from '@bitgo/public-types'; import { NonEmptyString } from 'io-ts-types'; @@ -12,10 +19,13 @@ import { BitGo, BitgoGPGPublicKey } from '../../../../../../src'; const SAFE_ID = '6fa8537e3ef5a878fd3ae899f3ab7e5a'; const USER_ROOT_KEY_ID = 'root-user-key-id'; -const DERIVATION_INDEX = 0; +const ACCOUNT = 0; const BITGO_ROOT_KEY_ID = 'root-bitgo-key-id'; const BACKUP_ROOT_KEY_ID = 'root-backup-key-id'; -const PATH_M0 = new Uint8Array([0x80, 0x00, 0x00, 0x00]); +// hteth: coinType 60, ecdsaMpc slot 3. +const childPath = (account: number) => buildSafeBip44Path(60, 'ecdsaMpc', account); +const derivePathBytes = (account: number) => + DklsVrfUtils.buildSafeMpcDerivePath({ coinType: 60, safeSlotOrdinal: 3, accountIndex: account }); describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async function () { const coinName = 'hteth'; @@ -75,7 +85,14 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct it('should derive the user child and register the backup without encryptedPrv', async function () { const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); const [vrfUser, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); - const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const bitgoPair = new DklsDrv.Derive( + 3, + 2, + 2, + bitgoRoot.getKeyShare(), + vrfBitgo.getKeyShare(), + derivePathBytes(ACCOUNT) + ); const round1Nock = await nockDeriveRound1(bitgoPair); const round2Nock = await nockDeriveRound2(bitgoPair); @@ -87,7 +104,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct enterprise: enterpriseId, safeId: SAFE_ID, parentKeyId: BITGO_ROOT_KEY_ID, - derivationIndex: DERIVATION_INDEX, + path: childPath(ACCOUNT), userRootKeyId: USER_ROOT_KEY_ID, backupRootKeyId: BACKUP_ROOT_KEY_ID, userRootKeyShare: userRoot.getKeyShare(), @@ -115,12 +132,18 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct assert.ok(userChildShare.s_i); }); - it('should derive at a non-zero hardened index and agree with the server', async function () { + it('should derive at a non-zero account and agree with the server', async function () { const idx = 7; - const path = new Uint8Array([0x80 | (idx >>> 24), 0, 0, idx]); const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); const [vrfUser, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); - const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), path); + const bitgoPair = new DklsDrv.Derive( + 3, + 2, + 2, + bitgoRoot.getKeyShare(), + vrfBitgo.getKeyShare(), + derivePathBytes(idx) + ); const round1Nock = await nockDeriveRound1(bitgoPair, 1, idx); const round2Nock = await nockDeriveRound2(bitgoPair); @@ -132,7 +155,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct enterprise: enterpriseId, safeId: SAFE_ID, parentKeyId: BITGO_ROOT_KEY_ID, - derivationIndex: idx, + path: childPath(idx), userRootKeyId: USER_ROOT_KEY_ID, backupRootKeyId: BACKUP_ROOT_KEY_ID, userRootKeyShare: userRoot.getKeyShare(), @@ -147,10 +170,74 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct assert.equal(bitgoKeychain.commonKeychain, userKeychain.commonKeychain); }); - it('registers the BitGo child placeholder with the hardened path and no private material', async function () { + it('lands on the same child key that offline recovery derives at the same path', async function () { + const [userRoot, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), derivePathBytes(5)); + + await nockDeriveRound1(bitgoPair, 1, 5); + await nockDeriveRound2(bitgoPair); + await nockDeriveRound3(bitgoPair); + await nockAddChildKey(coinName, 3, 5); + + const { userKeychain } = await tssUtils.createSafeChildKeychains({ + passphrase: 'test', + enterprise: enterpriseId, + safeId: SAFE_ID, + parentKeyId: BITGO_ROOT_KEY_ID, + path: childPath(5), + userRootKeyId: USER_ROOT_KEY_ID, + backupRootKeyId: BACKUP_ROOT_KEY_ID, + userRootKeyShare: userRoot.getKeyShare(), + userRootVrfKeyShare: vrfUser.getKeyShare(), + }); + + const recovered = await DklsVrfUtils.deriveSafeEcdsaMpcChild({ + userRoot: { signing: userRoot.getReducedKeyShare(), vrf: vrfUser.getKeyShare() }, + backupRoot: { signing: backupRoot.getReducedKeyShare(), vrf: vrfBackup.getKeyShare() }, + account: 5, + coinType: 60, + safeSlotOrdinal: 3, + }); + assert.equal(userKeychain.commonKeychain, recovered.commonKeychain); + }); + + for (const badPath of ["m/0'", 'm/44/60/3/0', "m/44'/60'/9'/0'"]) { + it(`rejects the non-BIP44 path ${JSON.stringify(badPath)} before any request`, async function () { + const [userRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const scope = nock(bgUrl).post('/api/v2/mpc/generatekey').reply(500); + + await assert.rejects( + () => + tssUtils.createSafeChildKeychains({ + passphrase: 'test', + enterprise: enterpriseId, + safeId: SAFE_ID, + parentKeyId: BITGO_ROOT_KEY_ID, + path: badPath as DerivedFromParentWithHardenedPath, + userRootKeyId: USER_ROOT_KEY_ID, + backupRootKeyId: BACKUP_ROOT_KEY_ID, + userRootKeyShare: userRoot.getKeyShare(), + userRootVrfKeyShare: vrfUser.getKeyShare(), + }), + /Invalid safe BIP44 path/ + ); + assert.ok(!scope.isDone(), 'no derive round may be sent for a non-BIP44 path'); + }); + } + + it('registers the BitGo child placeholder with the BIP44 path and no private material', async function () { const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); const [vrfUser, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); - const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const bitgoPair = new DklsDrv.Derive( + 3, + 2, + 2, + bitgoRoot.getKeyShare(), + vrfBitgo.getKeyShare(), + derivePathBytes(ACCOUNT) + ); await nockDeriveRound1(bitgoPair); await nockDeriveRound2(bitgoPair); @@ -170,7 +257,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct enterprise: enterpriseId, safeId: SAFE_ID, parentKeyId: BITGO_ROOT_KEY_ID, - derivationIndex: DERIVATION_INDEX, + path: childPath(ACCOUNT), userRootKeyId: USER_ROOT_KEY_ID, backupRootKeyId: BACKUP_ROOT_KEY_ID, userRootKeyShare: userRoot.getKeyShare(), @@ -184,12 +271,12 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct // Same common keychain across all three child registrations. assert.equal(bitgoBody.commonKeychain, userBody.commonKeychain); assert.equal(bitgoBody.commonKeychain, bitgoKeychain.commonKeychain); - // All child paths are hardened. - assert.equal(userBody.derivedFromParentWithPath, "m/0'"); + // All child paths are the hardened BIP44 path. + assert.equal(userBody.derivedFromParentWithPath, "m/44'/60'/3'/0'"); assert.equal(userBody.parent, USER_ROOT_KEY_ID); const backupBody = addKeyBodies.find((body) => body.source === 'backup'); assert.ok(backupBody, 'expected a backup child registration'); - assert.equal(backupBody.derivedFromParentWithPath, "m/0'"); + assert.equal(backupBody.derivedFromParentWithPath, "m/44'/60'/3'/0'"); assert.equal(backupBody.parent, BACKUP_ROOT_KEY_ID); // Exact wire body: public-only placeholder with no private material. assert.deepEqual(bitgoBody, { @@ -199,14 +286,21 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct commonKeychain: userBody.commonKeychain, safeId: SAFE_ID, parent: BITGO_ROOT_KEY_ID, - derivedFromParentWithPath: "m/0'", + derivedFromParentWithPath: "m/44'/60'/3'/0'", }); }); it('should reject root key material that is not a valid VRF envelope', async function () { const [, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); const [, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); - const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const bitgoPair = new DklsDrv.Derive( + 3, + 2, + 2, + bitgoRoot.getKeyShare(), + vrfBitgo.getKeyShare(), + derivePathBytes(ACCOUNT) + ); const round1Nock = await nockDeriveRound1(bitgoPair); const round2Nock = await nockDeriveRound2(bitgoPair); const round3Nock = await nockDeriveRound3(bitgoPair); @@ -218,7 +312,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct enterprise: enterpriseId, safeId: SAFE_ID, parentKeyId: BITGO_ROOT_KEY_ID, - derivationIndex: DERIVATION_INDEX, + path: childPath(ACCOUNT), userRootKeyId: USER_ROOT_KEY_ID, backupRootKeyId: BACKUP_ROOT_KEY_ID, userRootKeyShare: Buffer.from('garbage'), @@ -234,7 +328,14 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct it('should reject a root blob with a VRF partyId mismatch', async function () { const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); const [, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); - const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const bitgoPair = new DklsDrv.Derive( + 3, + 2, + 2, + bitgoRoot.getKeyShare(), + vrfBitgo.getKeyShare(), + derivePathBytes(ACCOUNT) + ); const round1Nock = await nockDeriveRound1(bitgoPair); const round2Nock = await nockDeriveRound2(bitgoPair); const round3Nock = await nockDeriveRound3(bitgoPair); @@ -246,7 +347,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct enterprise: enterpriseId, safeId: SAFE_ID, parentKeyId: BITGO_ROOT_KEY_ID, - derivationIndex: DERIVATION_INDEX, + path: childPath(ACCOUNT), userRootKeyId: USER_ROOT_KEY_ID, backupRootKeyId: BACKUP_ROOT_KEY_ID, userRootKeyShare: userRoot.getKeyShare(), @@ -274,7 +375,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct return bitgoGPGPublicKeyResponse; } - async function nockDeriveRound1(bitgoPair: DklsDrv.Derive, times = 1, index = DERIVATION_INDEX) { + async function nockDeriveRound1(bitgoPair: DklsDrv.Derive, times = 1, account = ACCOUNT) { return nock(bgUrl) .post( '/api/v2/mpc/generatekey', @@ -282,7 +383,8 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct body.round === 'MPCv2Derive-R1' && body.safeId === SAFE_ID && body.payload?.parentKeyId === BITGO_ROOT_KEY_ID && - body.payload?.derivationIndex === index && + // TODO(WCN-2969): R1 carries the path once public-types adds it; today the account rides as derivationIndex. + body.payload?.derivationIndex === account && body.payload?.userGpgPublicKey && body.payload?.userMsg1 && body.payload?.backupGpgPublicKey === undefined && @@ -381,9 +483,9 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct } /** - * Nocks the hardened child key registrations. + * Nocks the BIP44 child key registrations. */ - async function nockAddChildKey(coin: string, times = 3, index = DERIVATION_INDEX) { + async function nockAddChildKey(coin: string, times = 3, account = ACCOUNT) { return nock('https://bitgo.fakeurl') .post( `/api/v2/${coin}/key`, @@ -392,7 +494,7 @@ describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async funct body.isMPCv2 === true && body.safeId === SAFE_ID && !!body.parent && - body.derivedFromParentWithPath === `m/${index}'` + body.derivedFromParentWithPath === childPath(account) ) .times(times) .reply(200, (uri, requestBody: AddKeychainOptions) => ({ diff --git a/modules/express/test/unit/clientRoutes/generateSafe.ts b/modules/express/test/unit/clientRoutes/generateSafe.ts index 8a1066d7431..5f904a98697 100644 --- a/modules/express/test/unit/clientRoutes/generateSafe.ts +++ b/modules/express/test/unit/clientRoutes/generateSafe.ts @@ -258,8 +258,8 @@ describe('Generate Safe (typed handler)', () => { const derivationNock = nock(bgUrl) .get(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/derivation-index`) - .query({ slot: 'secp256k1Multisig' }) - .reply(200, { slot: 'secp256k1Multisig', index: 0 }); + .query({ slot: 'secp256k1Multisig', coin: 'tbtc' }) + .reply(200, { slot: 'secp256k1Multisig', coinType: 0, account: 0 }); const getRootKeyNock = nock(bgUrl).get(`/api/v2/tbtc/key/${USER_ROOT_ID}`).reply(200, { id: USER_ROOT_ID, @@ -269,9 +269,14 @@ describe('Generate Safe (typed handler)', () => { }); const addChildNock = nock(bgUrl) - .post('/api/v2/tbtc/key', (body: { parent?: string; safeId?: string; source?: string }) => { - return body.parent === USER_ROOT_ID && body.safeId === SAFE_ID && body.source === 'user'; - }) + .post( + '/api/v2/tbtc/key', + (body: { parent?: string; safeId?: string; source?: string; derivedFromParentWithPath?: string }) => + body.parent === USER_ROOT_ID && + body.safeId === SAFE_ID && + body.source === 'user' && + body.derivedFromParentWithPath === "m/44'/0'/1'/0'" + ) .reply(200, { id: childKeyId }); const mintNock = nock(bgUrl) diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index fe40604b18a..6b022c54a92 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -50,7 +50,7 @@ export interface Keychain { reducedEncryptedPrv?: string; derivationPath?: string; derivedFromParentWithSeed?: string; - /** Hardened path from the safe parent (`m/'`). @experimental */ + /** BIP44 path from the safe parent: `m/44'/'/'/'` (user / MPC children) or its soft twin `m/44///` (multisig co-signers). @experimental */ derivedFromParentWithPath?: string; /** Safe root key id this child key was derived from (WCN-1172). */ parent?: string; @@ -200,7 +200,7 @@ export interface AddKeychainOptions { originalPasscodeEncryptionCode?: string; enterprise?: string; derivedFromParentWithSeed?: string; - /** Hardened path from the safe parent (`m/'`). @experimental */ + /** BIP44 path from the safe parent: `m/44'/'/'/'` (user / MPC children) or its soft twin `m/44///` (multisig co-signers). @experimental */ derivedFromParentWithPath?: string; /** Safe user-root key id this child was derived from. @experimental */ parent?: string; diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index ad0b382bbab..85c19f2e5fe 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -6,9 +6,17 @@ import { Buffer } from 'buffer'; import { randomBytes } from 'crypto'; import { bip32 } from '@bitgo/utxo-lib'; -import { coins, MAX_BIP32_INDEX, SAFE_ROOT_SLOT_ORDINALS } from '@bitgo/statics'; +import { MAX_BIP32_INDEX } from '@bitgo/statics'; import type { RootKeyType } from '@bitgo/public-types'; -import { Ed25519ChainCodeEntryCodec, parseSafeKeycardBox, type SafeKeycardRootEntry } from '@bitgo/sdk-lib-safes'; +import { + buildSafeBip44Path, + deriveSafeChildTriplet, + Ed25519ChainCodeEntryCodec, + getSafeBip44CoinType, + parseSafeBip44Path, + parseSafeKeycardBox, + type SafeKeycardRootEntry, +} from '@bitgo/sdk-lib-safes'; import { DklsVrfUtils, MpsVrfUtils } from '@bitgo/sdk-lib-mpc'; import { BitGoBase } from '../bitgoBase'; import { IncorrectPasswordError } from '../errors'; @@ -127,14 +135,6 @@ export async function decryptSafeKeycard(params: { }; } -function buildSafeUserPath(coinType: number, slotOrdinal: number, account: number): string { - return `m/44'/${coinType}'/${slotOrdinal}'/${account}'`; -} - -function buildSafeCosignerPath(coinType: number, slotOrdinal: number, account: number): string { - return `m/44/${coinType}/${slotOrdinal}/${account}`; -} - /** * Derives a Safe wallet's child keys at the BIP44 path for a given slot and account. * Supports `secp256k1Multisig`, `ecdsaMpc`, and `eddsaMpc`; `ed25519Multisig` remains @@ -150,35 +150,12 @@ export async function deriveSafeWalletKeys(params: { throw new Error(`deriveSafeWalletKeys: invalid account '${params.account}'`); } - const coinType = coins.get(params.coin).bip44CoinType; - if (coinType === undefined) { - throw new Error(`Coin '${params.coin}' has no BIP44 coin type and cannot derive a safe child`); - } - const slotOrdinal = SAFE_ROOT_SLOT_ORDINALS[params.slot]; const { account, roots } = params; + const path = buildSafeBip44Path(getSafeBip44CoinType(params.coin), params.slot, account); + const { coinType, slotOrdinal } = parseSafeBip44Path(path); if (params.slot === 'secp256k1Multisig') { - const root = roots.secp256k1Multisig; - const user = bip32.fromBase58(root.user).derivePath(buildSafeUserPath(coinType, slotOrdinal, account)); - const backup = bip32.fromBase58(root.backup).derivePath(buildSafeCosignerPath(coinType, slotOrdinal, account)); - const bitgo = bip32.fromBase58(root.bitgo).derivePath(buildSafeCosignerPath(coinType, slotOrdinal, account)); - - if (!user.privateKey || !backup.privateKey) { - throw new Error('deriveSafeWalletKeys: user and backup roots must be private (xprv)'); - } - if (bitgo.privateKey) { - throw new Error('deriveSafeWalletKeys: bitgo root must be public (xpub)'); - } - - return { - slot: 'secp256k1Multisig', - path: buildSafeUserPath(coinType, slotOrdinal, account), - keys: { - user: { prv: user.toBase58(), pub: user.neutered().toBase58() }, - backup: { prv: backup.toBase58(), pub: backup.neutered().toBase58() }, - bitgo: { pub: bitgo.neutered().toBase58() }, - }, - }; + return { slot: 'secp256k1Multisig', path, keys: deriveSafeChildTriplet(roots.secp256k1Multisig, path) }; } if (params.slot === 'ecdsaMpc') { @@ -191,7 +168,7 @@ export async function deriveSafeWalletKeys(params: { }); return { slot: 'ecdsaMpc', - path: buildSafeUserPath(coinType, slotOrdinal, account), + path, keys: { commonKeychain, user: userChild, backup: backupChild }, }; } @@ -206,7 +183,7 @@ export async function deriveSafeWalletKeys(params: { }); return { slot: 'eddsaMpc', - path: buildSafeUserPath(coinType, slotOrdinal, account), + path, keys: { commonKeychain, user: userChild, backup: backupChild }, }; } diff --git a/modules/sdk-core/src/bitgo/safe/safe.ts b/modules/sdk-core/src/bitgo/safe/safe.ts index f2bc799e774..f383edbec78 100644 --- a/modules/sdk-core/src/bitgo/safe/safe.ts +++ b/modules/sdk-core/src/bitgo/safe/safe.ts @@ -30,9 +30,11 @@ import { } from './iSafe'; import { coinForRoot, SAFE_ROOT_SLOTS } from './rootCoin'; import { + buildSafeBip44Path, deriveAndSelfCheckSafeChildHardened, deriveSafeChildEd25519Hardened, DerivedFromParentWithHardenedPath, + getSafeBip44CoinType, onchainSlotForCoin, tssSlotForCoin, } from '@bitgo/sdk-lib-safes'; @@ -46,7 +48,8 @@ const SafeRootKeySlot = t.keyof({ const GetDerivationIndexResponse = t.type({ slot: SafeRootKeySlot, - index: boundedInt(0, 0x7fffffff, 'derivationIndex'), + coinType: boundedInt(0, 0x7fffffff, 'coinType'), + account: boundedInt(0, 0x7fffffff, 'account'), }); const CreateWalletInSafeBody = t.union([ @@ -129,13 +132,13 @@ export class Safe implements ISafe { } /** - * Mint a child wallet: peek the sequential index, derive the child keys, register - * them, then mint. + * Mint a child wallet: peek the next account for (slot, coinType), derive the child keys at + * `m/44'/'/'/'`, register them, then mint. * - * `onchain`: hardened-derive the user child (`m/'`), register it - * public-only; backup and BitGo children are soft-derived on the server. + * `onchain`: hardened-derive the user child at that path, register it public-only; backup + * and BitGo children are soft-derived on the server at its soft twin. * - * `tss`: decrypt the user root blob, run the user/BitGo hard-derive ceremony, + * `tss`: decrypt the user root blob, run the user/BitGo hard-derive ceremony at that path, * register the ordered child documents, then mint. */ async createWallet(params: CreateSafeWalletOptions): Promise { @@ -151,12 +154,20 @@ export class Safe implements ISafe { const coin = this.bitgo.coin(params.coin); const slot = isTss ? tssSlotForCoin(coin.getChain()) : onchainSlotForCoin(coin.getChain()); - const indexResponse = await this.bitgo.get(this.url('/derivation-index')).query({ slot }).result(); + const indexResponse = await this.bitgo + .get(this.url('/derivation-index')) + .query({ slot, coin: params.coin }) + .result(); const peeked = decodeWithCodec(GetDerivationIndexResponse, indexResponse, 'GetDerivationIndexResponse'); if (peeked.slot !== slot) { throw new Error(`derivation-index returned slot '${peeked.slot}', expected '${slot}'`); } - const { index } = peeked; + // A statics version skew between SDK and server would put the child on another coin's tree. + const expectedCoinType = getSafeBip44CoinType(coin.getChain()); + if (peeked.coinType !== expectedCoinType) { + throw new Error(`derivation-index returned coinType ${peeked.coinType}, expected ${expectedCoinType}`); + } + const path = buildSafeBip44Path(peeked.coinType, slot, peeked.account); const safeData = rootIdFromSafe(this._safe, slot, 0) !== undefined ? this._safe : await this.fetchSafeData(); const userRootId = rootIdFromSafe(safeData, slot, 0); @@ -173,7 +184,7 @@ export class Safe implements ISafe { if (bitgoRootId === undefined) { throw new Error(`Safe ${this.id()} is missing rootKeys.hot.${slot} bitgo key`); } - return this.createTssWalletInSafe(coin, slot, userRootId, backupRootId, bitgoRootId, index, params); + return this.createTssWalletInSafe(coin, slot, userRootId, backupRootId, bitgoRootId, path, params); } const keychains = coin.keychains(); @@ -188,13 +199,8 @@ export class Safe implements ISafe { const derived = slot === 'ed25519Multisig' - ? deriveSafeChildEd25519Hardened(rootPrv, index) - : deriveAndSelfCheckSafeChildHardened(rootPrv, index); - const derivedFromParentWithHardenedPath = decodeWithCodec( - DerivedFromParentWithHardenedPath, - derived.derivationPath, - 'derivedFromParentWithHardenedPath' - ); + ? deriveSafeChildEd25519Hardened(rootPrv, path) + : deriveAndSelfCheckSafeChildHardened(rootPrv, path); const child = await keychains.add({ pub: derived.pub, @@ -202,7 +208,7 @@ export class Safe implements ISafe { keyType: 'independent', parent: userRootId, safeId: this.id(), - derivedFromParentWithPath: derivedFromParentWithHardenedPath, + derivedFromParentWithPath: derived.derivationPath, }); const childId = child.id; if (childId.length === 0) { @@ -230,7 +236,7 @@ export class Safe implements ISafe { userRootId: string, backupRootId: string, bitgoRootId: string, - index: number, + path: DerivedFromParentWithHardenedPath, params: CreateSafeWalletOptions ): Promise { if (slot !== 'ecdsaMpc' && slot !== 'eddsaMpc') { @@ -258,7 +264,7 @@ export class Safe implements ISafe { enterprise: this.enterpriseId(), safeId: this.id(), parentKeyId: bitgoRootId, - derivationIndex: index, + path, userRootKeyId: userRootId, backupRootKeyId: backupRootId, userRootKeyShare: userRootMaterial.signing, diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index eb98f026349..360e15bc54d 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -18,6 +18,7 @@ import { MPCv2SignatureShareRound1Output, MPCv2SignatureShareRound2Output, } from '@bitgo/public-types'; +import type { DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; import { Ecdsa } from '../../../../account-lib'; import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from '../../../keychain'; @@ -395,8 +396,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { encryptionVersion?: EncryptionVersion, enterprise?: string, safeId?: string, - // Safe child registration: the parent root key ID and derivation index. - child?: { parentKeyId?: string; index?: number } + // Safe child registration: the parent root key ID and the BIP44 child path. + child?: { parentKeyId?: string; path?: DerivedFromParentWithHardenedPath } ): Promise { let source: string; let encryptedPrv: string | undefined = undefined; @@ -412,7 +413,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { participantIndex === MPCv2PartiesEnum.BACKUP && safeId !== undefined && child?.parentKeyId !== undefined && - child.index !== undefined, + child.path !== undefined, `Private material is required for ${source} keychain` ); break; @@ -451,11 +452,6 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { throw new Error('Invalid participant index'); } - let derivedFromParentWithPath: string | undefined; - if (child?.index !== undefined) { - derivedFromParentWithPath = `m/${child.index}'`; - } - const recipientKeychainParams: AddKeychainOptions = { source, keyType: 'tss' as KeyType, @@ -465,7 +461,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { isMPCv2: true, safeId, parent: child?.parentKeyId, - derivedFromParentWithPath, + derivedFromParentWithPath: child?.path, }; if (webauthnInfo && participantIndex === MPCv2PartiesEnum.USER && privateMaterialBase64) { @@ -612,7 +608,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { encryptionVersion?: EncryptionVersion, enterprise?: string, safeId?: string, - child?: { parentKeyId?: string; index?: number } + child?: { parentKeyId?: string; path?: DerivedFromParentWithHardenedPath } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.USER, @@ -643,14 +639,14 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { }, encryptionVersion?: EncryptionVersion, safeId?: string, - child?: { parentKeyId?: string; index?: number } + child?: { parentKeyId?: string; path?: DerivedFromParentWithHardenedPath } ): Promise { if (privateMaterial === undefined) { // Safe backup child placeholder: no local private material, but always derived from a // parent root (hardened path). assert( - child?.parentKeyId !== undefined && child.index !== undefined, - 'Backup placeholder requires a parent key id and derivation index' + child?.parentKeyId !== undefined && child.path !== undefined, + 'Backup placeholder requires a parent key id and derivation path' ); } return this.createParticipantKeychain( @@ -691,7 +687,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { protected async addBitgoKeychain( commonKeychain: string, safeId?: string, - child?: { parentKeyId?: string; index?: number } + child?: { parentKeyId?: string; path?: DerivedFromParentWithHardenedPath } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BITGO, diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts index 4e95f32647d..5097ccc581e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts @@ -1,4 +1,4 @@ -import { DklsComms, DklsDkg, DklsDrv, DklsTypes, DklsVrf } from '@bitgo/sdk-lib-mpc'; +import { DklsComms, DklsDkg, DklsDrv, DklsTypes, DklsVrf, DklsVrfUtils } from '@bitgo/sdk-lib-mpc'; import assert from 'assert'; import { NonEmptyString } from 'io-ts-types'; import { @@ -11,6 +11,7 @@ import { MPCv2KeyGenRound2Response, MPCv2KeyGenStateEnum, } from '@bitgo/public-types'; +import { DerivedFromParentWithHardenedPath, parseSafeBip44Path } from '@bitgo/sdk-lib-safes'; import { KeychainsTriplet } from '../../../baseCoin'; import { DecryptedRetrofitPayload } from '../../../keychain/iKeychains'; import { EncryptionVersion } from '../../../../api'; @@ -63,19 +64,6 @@ function deserializeVrfMessages(blob: string, forParty: number): DklsTypes.Deser }; } -/** - * Encodes a hardened derivation index as the byte path the DKLS hard-derive wasm - * expects: one big-endian u32 with the hardened bit (0x80000000) set — i.e. the - * child path `m/'`. The server derives the same hardened path from the - * `derivationIndex` it receives on round 1. - */ -export function hardenedDerivationPath(index: number): Uint8Array { - if (!Number.isInteger(index) || index < 0 || index > 0x7fffffff) { - throw new Error(`Invalid derivation index: ${index}`); - } - return new Uint8Array([0x80 | (index >>> 24), (index >>> 16) & 0xff, (index >>> 8) & 0xff, index & 0xff]); -} - /** * EcdsaMPCv2Utils variant that runs the Ristretto VRF DKG alongside the signing DKLS * DKG inside the same MPCv2 keygen rounds, for safe MPC root creation. @@ -579,7 +567,7 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { } /** - * Runs the safe child hard-derive ceremony and registers child key documents. + * Runs the safe child hard-derive ceremony at the BIP44 `path` and registers child key documents. */ async createSafeChildKeychains(params: { passphrase: string; @@ -587,7 +575,8 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { safeId: string; /** Root key ID used for the derive. */ parentKeyId: string; - derivationIndex: number; + /** Safe child path `m/44'/'/'/'`; every child is registered at it. */ + path: DerivedFromParentWithHardenedPath; userRootKeyId: string; backupRootKeyId: string; userRootKeyShare: Buffer; @@ -596,6 +585,7 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; }): Promise { + const { coinType, slotOrdinal, account } = parseSafeBip44Path(params.path); const userGpgKey = await generateGPGKeyPair('secp256k1'); const { mpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true); const mpcv2Key = mpcv2PublicKey ?? this.bitgoMPCv2PublicGpgKey; @@ -615,14 +605,18 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { gpgKey: bitgoPublicGpgKey, }; - const path = hardenedDerivationPath(params.derivationIndex); + const derivePath = DklsVrfUtils.buildSafeMpcDerivePath({ + coinType, + safeSlotOrdinal: slotOrdinal, + accountIndex: account, + }); const userDeriveSession = new DklsDrv.Derive( 3, 2, MPCv2PartiesEnum.USER, params.userRootKeyShare, params.userRootVrfKeyShare, - path + derivePath ); // Round 1: send the user's first message. @@ -641,7 +635,8 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { userGpgKey.publicKey, round1Messages, params.parentKeyId, - params.derivationIndex + // TODO(WCN-2969): send `path` once the public-types derive round 1 request carries it. + account ); // Round 2: process the peer's first message and return the user's second. @@ -707,7 +702,7 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { params.encryptionVersion, params.enterprise, params.safeId, - { parentKeyId: params.userRootKeyId, index: params.derivationIndex } + { parentKeyId: params.userRootKeyId, path: params.path } ); const backupKeychainPromise = this.addBackupKeychain( commonKeychain, @@ -718,11 +713,11 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { encryptionSession, params.encryptionVersion, params.safeId, - { parentKeyId: params.backupRootKeyId, index: params.derivationIndex } + { parentKeyId: params.backupRootKeyId, path: params.path } ); const bitgoKeychainPromise = this.addBitgoKeychain(commonKeychain, params.safeId, { parentKeyId: params.parentKeyId, - index: params.derivationIndex, + path: params.path, }); const [userKeychain, backupKeychain, bitgoKeychain] = await Promise.all([ diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 28cecdb4f8e..411a5802877 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -15,7 +15,7 @@ import { import { EddsaMPCv2KeyGenCallbacks } from '../../../wallet/iWallets'; import { ed25519 } from '@noble/curves/ed25519'; import { EddsaMPSDkg, EddsaMPSDsg, MPSComms, MPSTypes, MPSUtil } from '@bitgo/sdk-lib-mpc'; -import { DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; +import type { DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; import { KeychainsTriplet } from '../../../baseCoin'; import { AddKeychainOptions, @@ -337,7 +337,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { encryptionVersion?: EncryptionVersion, enterprise?: string, safeId?: string, - child?: { parentKeyId: string; index: number } + child?: { parentKeyId: string; path: DerivedFromParentWithHardenedPath } ): Promise { let source: string; let encryptedPrv: string | undefined = undefined; @@ -402,11 +402,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { assert(NonEmptyString.is(safeId), 'Safe ID is required for a derived keychain'); assert(NonEmptyString.is(child.parentKeyId), 'Parent key ID is required for a derived keychain'); keychainParams.parent = child.parentKeyId; - keychainParams.derivedFromParentWithPath = decodeWithCodec( - DerivedFromParentWithHardenedPath, - `m/${child.index}'`, - 'derivedFromParentWithPath' - ); + keychainParams.derivedFromParentWithPath = child.path; } if (webauthnInfo && participantIndex === MPCv2PartiesEnum.USER && privateMaterialBase64) { @@ -438,7 +434,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { encryptionVersion?: EncryptionVersion, enterprise?: string, safeId?: string, - child?: { parentKeyId: string; index: number } + child?: { parentKeyId: string; path: DerivedFromParentWithHardenedPath } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.USER, @@ -464,7 +460,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { encryptionVersion?: EncryptionVersion, enterprise?: string, safeId?: string, - child?: { parentKeyId: string; index: number } + child?: { parentKeyId: string; path: DerivedFromParentWithHardenedPath } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BACKUP, @@ -484,7 +480,7 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { protected async addBitgoKeychain( commonKeychain: string, safeId?: string, - child?: { parentKeyId: string; index: number } + child?: { parentKeyId: string; path: DerivedFromParentWithHardenedPath } ): Promise { return this.createParticipantKeychain( MPCv2PartiesEnum.BITGO, diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts index 1aee562aabf..3525ffe4591 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -9,7 +9,7 @@ import { type EddsaMPCv2KeyGenRound2Response, type EddsaMPCv2SignedMessage, } from '@bitgo/public-types'; -import { DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; +import { DerivedFromParentWithHardenedPath, parseSafeBip44Path } from '@bitgo/sdk-lib-safes'; import assert from 'assert'; import * as pgp from 'openpgp'; import { NonEmptyString } from 'io-ts-types'; @@ -327,13 +327,14 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { ); } - /** Derives the user signing share with BitGo and registers all three safe children. */ + /** Derives the user signing share with BitGo at the BIP44 `path` and registers all three safe children. */ async createSafeChildKeychains(params: { passphrase: string; enterprise: string; safeId: string; parentKeyId: string; - derivationIndex: number; + /** Safe child path `m/44'/'/'/'`; every child is registered at it. */ + path: DerivedFromParentWithHardenedPath; userRootKeyId: string; backupRootKeyId: string; userRootKeyShare: Buffer; @@ -346,11 +347,8 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { assert(NonEmptyString.is(params.userRootKeyId), 'User root key ID is required for child derivation'); assert(NonEmptyString.is(params.backupRootKeyId), 'Backup root key ID is required for child derivation'); assert(NonEmptyString.is(params.parentKeyId), 'BitGo root key ID is required for child derivation'); - const path = decodeWithCodec( - DerivedFromParentWithHardenedPath, - `m/${params.derivationIndex}'`, - 'derivedFromParentWithPath' - ); + const { path } = params; + const { account } = parseSafeBip44Path(path); const userKeyPair = await generateGPGKeyPair('ed25519'); const userGpgKey = await pgp.readPrivateKey({ armoredKey: userKeyPair.privateKey }); @@ -379,7 +377,8 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { userKeyPair.publicKey, signedUserMsg1, params.parentKeyId, - params.derivationIndex + // TODO(WCN-2969): send `path` once the public-types derive round 1 request carries it. + account ); const [userMsg2] = userDeriveSession.handleIncomingMessages([ @@ -416,7 +415,7 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { params.encryptionVersion, params.enterprise, params.safeId, - { parentKeyId: params.userRootKeyId, index: params.derivationIndex } + { parentKeyId: params.userRootKeyId, path } ), this.addBackupKeychain( commonKeychain, @@ -427,11 +426,11 @@ export class EddsaVrfMPCv2Utils extends EddsaMPCv2Utils { params.encryptionVersion, params.enterprise, params.safeId, - { parentKeyId: params.backupRootKeyId, index: params.derivationIndex } + { parentKeyId: params.backupRootKeyId, path } ), this.addBitgoKeychain(commonKeychain, params.safeId, { parentKeyId: params.parentKeyId, - index: params.derivationIndex, + path, }), ]); return { userKeychain, backupKeychain, bitgoKeychain }; diff --git a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts index 95271ae5b85..66e99f4adad 100644 --- a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts +++ b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts @@ -3,7 +3,8 @@ */ import { BitGoBase } from '../bitgoBase'; import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain'; -import { deriveSafeChildHardenedFromXprv, parseDerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; +import { deriveSafeChildHardenedFromXprv, DerivedFromParentWithHardenedPath } from '@bitgo/sdk-lib-safes'; +import { decodeWithCodec } from '../utils/codecs'; import { IncorrectPasswordError } from '../errors'; import type { DecryptedKeychainData } from './iWallet'; @@ -148,7 +149,11 @@ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Pro const derived = deriveSafeChildHardenedFromXprv( rootPrv, - parseDerivedFromParentWithHardenedPath(childKeychain.derivedFromParentWithPath) + decodeWithCodec( + DerivedFromParentWithHardenedPath, + childKeychain.derivedFromParentWithPath, + 'derivedFromParentWithPath' + ) ); if (derived.pub !== childKeychain.pub) { throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub); @@ -164,7 +169,7 @@ async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Pro * Resolve signing material for a safe owner (child key has no encryptedPrv). * * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` - * (`m/'`), and verify the registered pub. + * (`m/44'/'/'/'`), and verify the registered pub. * TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve. * * Do not use for wallet sharing — that must not receive root key material. diff --git a/modules/sdk-core/test/unit/bitgo/safe/safe.ts b/modules/sdk-core/test/unit/bitgo/safe/safe.ts index 6846a234fd5..d6536cbedab 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safe.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safe.ts @@ -7,6 +7,7 @@ import { IncorrectPasswordError, buildSafeMpcKeyEnvelopes, Safe, + buildSafeBip44Path, deriveSafeChildEd25519Hardened, deriveSafeChildHardenedFromXprv, } from '../../../../src'; @@ -17,7 +18,9 @@ const ROOT_XPRV = // 32-byte synthetic seed, StrKey spelling generated with stellar-sdk; pinned derivation vectors in // test/unit/bitgo/safe/safeDerivation.ts. const ROOT_ED25519_SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; -const ed25519ChildAt0 = deriveSafeChildEd25519Hardened(ROOT_ED25519_SEED_STRKEY, 0); +// Every ed25519 wallet coin derives from the txlm StrKey root, at the wallet coin's own coinType. +const ed25519ChildFor = (coinType: number) => + deriveSafeChildEd25519Hardened(ROOT_ED25519_SEED_STRKEY, buildSafeBip44Path(coinType, 'ed25519Multisig', 0)); describe('Safe', function () { let safe: Safe; @@ -173,8 +176,8 @@ describe('Safe', function () { }); describe('createWallet (WCN-1203)', function () { - const childAt0 = deriveSafeChildHardenedFromXprv(ROOT_XPRV, 0); - const childAt7 = deriveSafeChildHardenedFromXprv(ROOT_XPRV, 7); + const childAt0 = deriveSafeChildHardenedFromXprv(ROOT_XPRV, buildSafeBip44Path(0, 'secp256k1Multisig', 0)); + const childAt7 = deriveSafeChildHardenedFromXprv(ROOT_XPRV, buildSafeBip44Path(0, 'secp256k1Multisig', 7)); let keychainsAdd: sinon.SinonStub; let keychainsGet: sinon.SinonStub; let derivationQuery: sinon.SinonStub; @@ -208,7 +211,7 @@ describe('Safe', function () { throw new Error('bad ciphertext'); }); derivationQuery = sinon.stub().returns({ - result: sinon.stub().resolves({ slot: 'secp256k1Multisig', index: 0 }), + result: sinon.stub().resolves({ slot: 'secp256k1Multisig', coinType: 0, account: 0 }), }); mockBitGo.get.returns({ query: derivationQuery }); const mintResult = sinon.stub().resolves({ @@ -224,10 +227,10 @@ describe('Safe', function () { mockBitGo.post.returns({ send: mintSend }); }); - it('peeks the index, registers a public-only user child, and mints', async function () { + it('peeks the account, registers a public-only user child at the BIP44 path, and mints', async function () { const wallet = await safe.createWallet({ coin: 'tbtc', label: 'desk 1', passphrase: 'pw' }); - derivationQuery.calledOnceWithExactly({ slot: 'secp256k1Multisig' }).should.be.true(); + derivationQuery.calledOnceWithExactly({ slot: 'secp256k1Multisig', coin: 'tbtc' }).should.be.true(); keychainsGet.calledOnceWithExactly({ id: 'user-root-id' }).should.be.true(); keychainsAdd.calledOnce.should.be.true(); const addArgs = keychainsAdd.firstCall.args[0]; @@ -237,7 +240,7 @@ describe('Safe', function () { keyType: 'independent', parent: 'user-root-id', safeId: 'test-safe-id', - derivedFromParentWithPath: "m/0'", + derivedFromParentWithPath: "m/44'/0'/1'/0'", }); addArgs.should.not.have.property('encryptedPrv'); addArgs.should.not.have.property('derivedFromParentWithSeed'); @@ -259,9 +262,9 @@ describe('Safe', function () { mintedSafeId.should.equal('test-safe-id'); }); - it('registers the child with derivedFromParentWithPath at a non-zero mint index', async function () { + it('registers the child at the peeked account', async function () { derivationQuery.returns({ - result: sinon.stub().resolves({ slot: 'secp256k1Multisig', index: 7 }), + result: sinon.stub().resolves({ slot: 'secp256k1Multisig', coinType: 0, account: 7 }), }); keychainsAdd.resolves({ id: 'child-key-id', pub: childAt7.pub, type: 'independent' }); @@ -269,17 +272,34 @@ describe('Safe', function () { const addArgs = keychainsAdd.firstCall.args[0]; addArgs.pub.should.equal(childAt7.pub); - addArgs.derivedFromParentWithPath.should.equal("m/7'"); + addArgs.derivedFromParentWithPath.should.equal("m/44'/0'/1'/7'"); addArgs.should.not.have.property('path'); addArgs.should.not.have.property('derivedFromParentWithSeed'); }); + it('rejects a server coinType that differs from local statics before registering anything', async function () { + derivationQuery.returns({ + result: sinon.stub().resolves({ slot: 'secp256k1Multisig', coinType: 60, account: 0 }), + }); + + await safe + .createWallet({ coin: 'tbtc', label: 'desk 1', passphrase: 'pw' }) + .should.be.rejectedWith(/returned coinType 60, expected 0/); + keychainsGet.notCalled.should.be.true(); + keychainsAdd.notCalled.should.be.true(); + mintSend.notCalled.should.be.true(); + }); + it('mints hteth onchain from secp256k1Multisig roots when default wallet type is tss', async function () { stubCoin('hteth', { getDefaultMultisigType: 'tss' }); + derivationQuery.returns({ + result: sinon.stub().resolves({ slot: 'secp256k1Multisig', coinType: 60, account: 0 }), + }); await safe.createWallet({ coin: 'hteth', label: 'evm onchain', passphrase: 'pw' }); - derivationQuery.calledOnceWithExactly({ slot: 'secp256k1Multisig' }).should.be.true(); + derivationQuery.calledOnceWithExactly({ slot: 'secp256k1Multisig', coin: 'hteth' }).should.be.true(); + keychainsAdd.firstCall.args[0].derivedFromParentWithPath.should.equal("m/44'/60'/1'/0'"); keychainsGet.calledOnceWithExactly({ id: 'user-root-id' }).should.be.true(); mintSend.firstCall.args[0].should.containEql({ coin: 'hteth', @@ -304,7 +324,7 @@ describe('Safe', function () { .callsFake(({ input }: { input: string }) => Promise.resolve(input.startsWith('enc:') ? input.slice(4) : '')); derivationQuery.returns({ - result: sinon.stub().resolves({ slot: 'ecdsaMpc', index: 0 }), + result: sinon.stub().resolves({ slot: 'ecdsaMpc', coinType: 60, account: 0 }), }); const ceremonyStub = sinon.stub(ECDSAUtils.EcdsaVrfMPCv2Utils.prototype, 'createSafeChildKeychains').resolves({ @@ -315,17 +335,18 @@ describe('Safe', function () { const wallet = await safe.createWallet({ coin: 'hteth', label: 'evm', passphrase: 'pw', multisigType: 'tss' }); - derivationQuery.calledOnceWithExactly({ slot: 'ecdsaMpc' }).should.be.true(); + derivationQuery.calledOnceWithExactly({ slot: 'ecdsaMpc', coin: 'hteth' }).should.be.true(); keychainsGet.calledOnceWithExactly({ id: 'ecdsa-user' }).should.be.true(); const ceremonyArgs = ceremonyStub.firstCall.args[0]; ceremonyArgs.should.containEql({ safeId: 'test-safe-id', enterprise: 'test-enterprise-id', parentKeyId: 'ecdsa-bitgo', - derivationIndex: 0, + path: "m/44'/60'/3'/0'", userRootKeyId: 'ecdsa-user', backupRootKeyId: 'ecdsa-backup', }); + ceremonyArgs.should.not.have.property('derivationIndex'); ceremonyArgs.should.not.have.property('backupRootKeyShare'); ceremonyArgs.should.not.have.property('backupRootVrfKeyShare'); ceremonyArgs.userRootKeyShare.should.deepEqual(Buffer.from('signing-1')); @@ -351,7 +372,7 @@ describe('Safe', function () { ).envelope.toString('base64'); keychainsGet.resolves({ id: 'eddsa-user', source: 'user', encryptedPrv: `enc:${userBlob}` }); derivationQuery.returns({ - result: sinon.stub().resolves({ slot: 'eddsaMpc', index: 7 }), + result: sinon.stub().resolves({ slot: 'eddsaMpc', coinType: 501, account: 7 }), }); const ceremonyStub = sinon.stub(EDDSAUtils.EddsaVrfMPCv2Utils.prototype, 'createSafeChildKeychains').resolves({ @@ -367,7 +388,7 @@ describe('Safe', function () { multisigType: 'tss', }); - derivationQuery.calledOnceWithExactly({ slot: 'eddsaMpc' }).should.be.true(); + derivationQuery.calledOnceWithExactly({ slot: 'eddsaMpc', coin: chain }).should.be.true(); keychainsGet.calledOnceWithExactly({ id: 'eddsa-user' }).should.be.true(); ceremonyStub.calledOnce.should.be.true(); const ceremonyArgs = ceremonyStub.firstCall.args[0]; @@ -376,12 +397,13 @@ describe('Safe', function () { enterprise: 'test-enterprise-id', safeId: 'test-safe-id', parentKeyId: 'eddsa-bitgo', - derivationIndex: 7, + path: "m/44'/501'/4'/7'", userRootKeyId: 'eddsa-user', backupRootKeyId: 'eddsa-backup', }); ceremonyArgs.userRootKeyShare.should.deepEqual(Buffer.from('eddsa-root-signing')); ceremonyArgs.userRootVrfKeyShare.should.deepEqual(Buffer.from('eddsa-root-vrf')); + ceremonyArgs.should.not.have.property('derivationIndex'); ceremonyArgs.should.not.have.property('backupRootKeyShare'); ceremonyArgs.should.not.have.property('backupRootVrfKeyShare'); keychainsAdd.notCalled.should.be.true(); @@ -396,21 +418,21 @@ describe('Safe', function () { }); } - it('rejects a Solana derivation index from the wrong MPC slot before decrypting or minting', async function () { + it('rejects a Solana derivation account from the wrong MPC slot before decrypting or minting', async function () { stubCoin('tsol', { getDefaultMultisigType: 'tss' }); - derivationQuery.returns({ result: sinon.stub().resolves({ slot: 'ecdsaMpc', index: 0 }) }); + derivationQuery.returns({ result: sinon.stub().resolves({ slot: 'ecdsaMpc', coinType: 501, account: 0 }) }); await safe .createWallet({ coin: 'tsol', label: 'sol desk', passphrase: 'pw', multisigType: 'tss' }) .should.be.rejectedWith(/returned slot 'ecdsaMpc', expected 'eddsaMpc'/); - derivationQuery.calledOnceWithExactly({ slot: 'eddsaMpc' }).should.be.true(); + derivationQuery.calledOnceWithExactly({ slot: 'eddsaMpc', coin: 'tsol' }).should.be.true(); keychainsGet.notCalled.should.be.true(); mintSend.notCalled.should.be.true(); }); - it('rejects a peeked derivation index for the wrong slot', async function () { + it('rejects a peeked derivation account for the wrong slot', async function () { derivationQuery.returns({ - result: sinon.stub().resolves({ slot: 'ecdsaMpc', index: 0 }), + result: sinon.stub().resolves({ slot: 'ecdsaMpc', coinType: 0, account: 0 }), }); await safe .createWallet({ coin: 'tbtc', label: 'desk 1', passphrase: 'pw' }) @@ -426,23 +448,24 @@ describe('Safe', function () { pub: 'GAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3QZ6Q', type: 'independent', }); - keychainsAdd.resolves({ id: 'child-key-id', pub: ed25519ChildAt0.pub, type: 'independent' }); + const ed25519Child = ed25519ChildFor(148); + keychainsAdd.resolves({ id: 'child-key-id', pub: ed25519Child.pub, type: 'independent' }); derivationQuery.returns({ - result: sinon.stub().resolves({ slot: 'ed25519Multisig', index: 0 }), + result: sinon.stub().resolves({ slot: 'ed25519Multisig', coinType: 148, account: 0 }), }); await safe.createWallet({ coin: 'txlm', label: 'xlm desk', passphrase: 'pw' }); - derivationQuery.calledOnceWithExactly({ slot: 'ed25519Multisig' }).should.be.true(); + derivationQuery.calledOnceWithExactly({ slot: 'ed25519Multisig', coin: 'txlm' }).should.be.true(); keychainsGet.calledOnceWithExactly({ id: 'ed-user' }).should.be.true(); const addArgs = keychainsAdd.firstCall.args[0]; addArgs.should.eql({ - pub: ed25519ChildAt0.pub, + pub: ed25519Child.pub, source: 'user', keyType: 'independent', parent: 'ed-user', safeId: 'test-safe-id', - derivedFromParentWithPath: "m/0'", + derivedFromParentWithPath: "m/44'/148'/2'/0'", }); addArgs.should.not.have.property('encryptedPrv'); mintSend.firstCall.args[0].should.eql({ @@ -454,7 +477,10 @@ describe('Safe', function () { }); }); - for (const chain of ['talgo', 'thbar']) { + for (const [chain, coinType] of [ + ['talgo', 283], + ['thbar', 3030], + ] as const) { it(`mints an ed25519 ${chain} wallet posting the user child as a StrKey (server converts)`, async function () { // The user child is hardened-derived from the txlm StrKey user root, so the SDK posts a // Stellar StrKey for EVERY ed25519 wallet coin. Wallet Platform converts the pub to the @@ -469,19 +495,19 @@ describe('Safe', function () { }); keychainsAdd.resolves({ id: 'child-key-id', pub: `converted-${chain}-pub`, type: 'independent' }); derivationQuery.returns({ - result: sinon.stub().resolves({ slot: 'ed25519Multisig', index: 0 }), + result: sinon.stub().resolves({ slot: 'ed25519Multisig', coinType, account: 0 }), }); await safe.createWallet({ coin: chain, label: `${chain} desk`, passphrase: 'pw' }); const addArgs = keychainsAdd.firstCall.args[0]; addArgs.should.eql({ - pub: ed25519ChildAt0.pub, + pub: ed25519ChildFor(coinType).pub, source: 'user', keyType: 'independent', parent: 'ed-user', safeId: 'test-safe-id', - derivedFromParentWithPath: "m/0'", + derivedFromParentWithPath: `m/44'/${coinType}'/2'/0'`, }); mintSend.firstCall.args[0].should.eql({ coin: chain, diff --git a/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts b/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts index 73fd46f45e5..d2575aa8565 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts @@ -1,10 +1,5 @@ import 'should'; -import { - deriveSafeChildEd25519Hardened, - deriveSafeChildHardenedFromXprv, - getSafeHardenedDerivationPath, - parseSafeDerivationIndex, -} from '../../../../src'; +import { buildSafeBip44Path, deriveSafeChildEd25519Hardened, deriveSafeChildHardenedFromXprv } from '../../../../src'; // Deliberate guard: re-derives through sdk-core's public barrel (which re-exports from // @bitgo/sdk-lib-safes) to prove the re-export and byte-for-byte output hold end-to-end. @@ -12,85 +7,41 @@ import { // test/unit/safeDerivation.ts pins a public BIP32 vector instead. // 32-byte synthetic root seed. The StrKey spelling was generated with stellar-sdk -// (Keypair.fromRawEd25519Seed); the derivation vectors below were generated with an independent -// SLIP-0010 implementation written from the spec and cross-checked against published vectors -// (SLIP-0010 test vector 1 seed 000102...0f derives m/0' to 68e0fe46...dade7a3). +// (Keypair.fromRawEd25519Seed); the leaf pins the same seed at the same BIP44 path. const ROOT_SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; const ROOT_XPRV = 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; describe('safeDerivation', function () { - describe('parseSafeDerivationIndex', function () { - it('accepts numbers and digit strings', function () { - parseSafeDerivationIndex(0).should.equal(0); - parseSafeDerivationIndex(7).should.equal(7); - parseSafeDerivationIndex('42').should.equal(42); - parseSafeDerivationIndex(0x7fffffff).should.equal(0x7fffffff); - }); - - it('rejects negatives, non-integers, and out-of-range values', function () { - (() => parseSafeDerivationIndex(-1)).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex(1.5)).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex(0x80000000)).should.throw(/Invalid safe derivation index/); - }); - - it('rejects non-numeric strings', function () { - (() => parseSafeDerivationIndex("0'")).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex('m/0')).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex('')).should.throw(/Invalid safe derivation index/); - }); - }); - - describe('getSafeHardenedDerivationPath', function () { - it('formats m/ with hardened apostrophe', function () { - getSafeHardenedDerivationPath(0).should.equal("m/0'"); - getSafeHardenedDerivationPath('7').should.equal("m/7'"); - getSafeHardenedDerivationPath('007').should.equal("m/7'"); - }); - }); - describe('deriveSafeChildHardenedFromXprv', function () { - it('derives m/0 from the root xprv', function () { - const child = deriveSafeChildHardenedFromXprv(ROOT_XPRV, 0); - child.derivationPath.should.equal("m/0'"); + it("derives m/44'/0'/1'/0' from the root xprv", function () { + const child = deriveSafeChildHardenedFromXprv(ROOT_XPRV, buildSafeBip44Path(0, 'secp256k1Multisig', 0)); + child.derivationPath.should.equal("m/44'/0'/1'/0'"); child.pub.should.equal( - 'xpub69PbR6HB6ZaW3Q9CWAzNsmWXC8TBDq1VEmd25XkwUgrU3PVGAbj6bksqPnGWcFdAodXWRpWMXJ5KGim45n55cZjXeW7FDw4BqahtxTEN4wB' + 'xpub6DpagSiper5rGnEAK4gGmRJPdgDQCzmWZSY59iJ63NUw6BecfdX71APcU5Wfrq3rCcaFoah1dh7ot2fp1nt5AJMJysxSouPCT5xuHtptAaz' ); child.prv.should.equal( - 'xprv9vQF1akHGC2Cpv4jQ9TNWdZne6cgpNHdsYhRH9MKvMKVAbA7d4Qr3xZMYXqAS35V4damCDP2hYohCLViHzcGhX4Tr7djjCBruAX73SsjCiC' + 'xprv9zqEGwBvpUXZ4J9hD39GQHMf5eNuoY3fCDcUMKtUV2wxDPKU86CrTN58cpUVsjJFu4XqVDZS8SFFX11kZfoRxY7pgaHL2NCqPye1oVPDuad' ); }); }); describe('deriveSafeChildEd25519Hardened', function () { - it('derives m/0 to the pinned SLIP-0010 vector', function () { - const child = deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, 0); - child.should.eql({ - prv: 'b127eb5092011c085345c8ce0bfeda6064f9e1249e29cc238c1d64bf2e587ce7', - pub: 'GCTZR46FPFAMYN734SQB4NCNBI44M4DSNM5RJPCDLOMAOFPEUVUXPK7R', - derivationPath: "m/0'", - }); - }); - - it('derives m/7 to the pinned SLIP-0010 vector', function () { - const child = deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, '7'); - child.should.eql({ - prv: 'd54701e221cf51e9e208a7c59e3fe3e4cfbb6b91fd3f35ce092a471c35228217', - pub: 'GC2Y5EU2XA22SOSCRSZRNDEY3UAA4OJSLZ5NQUFUZDBQSLMVCIZCBHIN', - derivationPath: "m/7'", + it("derives m/44'/148'/2'/0' to the pinned SLIP-0010 vector", function () { + deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, buildSafeBip44Path(148, 'ed25519Multisig', 0)).should.eql({ + prv: '855e8d991b48037b3e0161fd57f1643044878d6afe0db0243719b5201e2e4f69', + pub: 'GDD3VVFKM64URLG6PO2UFWQXJH67N5UDOV5HZ23M5FJJT5G4LUO7RJWG', + derivationPath: "m/44'/148'/2'/0'", }); }); it('rejects a malformed root seed', function () { - (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY.slice(0, 55), 0)).should.throw( + const path = buildSafeBip44Path(148, 'ed25519Multisig', 0); + (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY.slice(0, 55), path)).should.throw( /Invalid ed25519 StrKey secret seed/ ); - (() => deriveSafeChildEd25519Hardened(ROOT_XPRV, 0)).should.throw(/Invalid ed25519 StrKey secret seed/); - }); - - it('rejects an invalid index', function () { - (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, -1)).should.throw(/Invalid safe derivation index/); + (() => deriveSafeChildEd25519Hardened(ROOT_XPRV, path)).should.throw(/Invalid ed25519 StrKey secret seed/); }); }); }); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts index 30740004b48..87e4eb37e9f 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaVrfMPCv2.ts @@ -13,7 +13,14 @@ import { import { decode } from 'cbor-x'; import * as t from 'io-ts'; -import { EddsaMPCv2Utils, BitGoBase, IBaseCoin, Keychain } from '../../../../../../src'; +import { + buildSafeBip44Path, + DerivedFromParentWithHardenedPath, + EddsaMPCv2Utils, + BitGoBase, + IBaseCoin, + Keychain, +} from '../../../../../../src'; import { decodeWithCodec } from '../../../../../../src/bitgo/utils/codecs'; import { buildSafeMpcKeyEnvelopes } from '../../../../../../src/bitgo/utils/tss/keyShareEnvelope'; import { EddsaVrfMPCv2Utils } from '../../../../../../src/bitgo/utils/tss/eddsa/eddsaVrfMPCv2'; @@ -447,8 +454,8 @@ describe('EdDSA safe-child MPS derivation', function () { let bitgoPublicKey: pgp.Key; const sessionId = 'derive-session'; - const derivationIndex = 7; - const path = "m/7'"; + const account = 7; + const path = buildSafeBip44Path(501, 'eddsaMpc', account); const Request = t.intersection([ t.type({ enterprise: t.string, type: t.string, curveType: t.string, round: t.string, payload: t.unknown }), t.partial({ safeId: t.string }), @@ -500,7 +507,7 @@ describe('EdDSA safe-child MPS derivation', function () { 'userMsg1', ]); assert.strictEqual(request.parentKeyId, 'bitgo-root-id'); - assert.strictEqual(request.derivationIndex, derivationIndex); + assert.strictEqual(request.derivationIndex, account); userGpgPublicKey = request.userGpgPublicKey; const userGpgKey = await pgp.readKey({ armoredKey: request.userGpgPublicKey }); const userPayload = await MPSComms.verifyMpsMessage(request.userMsg1, userGpgKey); @@ -553,7 +560,7 @@ describe('EdDSA safe-child MPS derivation', function () { enterprise: 'enterprise-id', safeId: 'safe-id', parentKeyId: 'bitgo-root-id', - derivationIndex, + path, userRootKeyId: 'user-root-id', backupRootKeyId: 'backup-root-id', userRootKeyShare: userSigningShare, @@ -610,27 +617,29 @@ describe('EdDSA safe-child MPS derivation', function () { assert.strictEqual(bitgoChild.encryptedPrv, undefined); }); - it('rejects indexes outside the hardened Safe range before contacting WP', async function () { - const { bitgo, baseCoin, post, addedKeychains } = createKeychainFixture(); - const utils = new EddsaVrfMPCv2Utils(bitgo, baseCoin); + for (const badPath of ["m/7'", 'm/44/501/4/7', "m/44'/501'/0'/7'"]) { + it(`rejects the non-BIP44 path ${JSON.stringify(badPath)} before contacting WP`, async function () { + const { bitgo, baseCoin, post, addedKeychains } = createKeychainFixture(); + const utils = new EddsaVrfMPCv2Utils(bitgo, baseCoin); - await assert.rejects( - utils.createSafeChildKeychains({ - passphrase: 'passphrase', - enterprise: 'enterprise-id', - safeId: 'safe-id', - parentKeyId: 'bitgo-root-id', - derivationIndex: 0x80000000, - userRootKeyId: 'user-root-id', - backupRootKeyId: 'backup-root-id', - userRootKeyShare: userSigningShare, - userRootVrfKeyShare: userVrfShare, - }), - /derivedFromParentWithPath/ - ); - assert.strictEqual(post.called, false); - assert.deepStrictEqual(addedKeychains, []); - }); + await assert.rejects( + utils.createSafeChildKeychains({ + passphrase: 'passphrase', + enterprise: 'enterprise-id', + safeId: 'safe-id', + parentKeyId: 'bitgo-root-id', + path: badPath as DerivedFromParentWithHardenedPath, + userRootKeyId: 'user-root-id', + backupRootKeyId: 'backup-root-id', + userRootKeyShare: userSigningShare, + userRootVrfKeyShare: userVrfShare, + }), + /Invalid safe BIP44 path/ + ); + assert.strictEqual(post.called, false); + assert.deepStrictEqual(addedKeychains, []); + }); + } const faults: Array<'r1-signature' | 'r2-signature' | 'session' | 'commonKeychain'> = [ 'r1-signature', diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts index 17338f3a63e..9ec0e1ae1a0 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts @@ -5,10 +5,9 @@ import 'should'; import * as sinon from 'sinon'; import { bip32 } from '@bitgo/utxo-lib'; import { + buildSafeBip44Path, deriveSafeChildHardenedFromXprv, fetchRootKeychainForSafeChild, - getSafeHardenedDerivationPath, - parseDerivedFromParentWithHardenedPath, IncorrectPasswordError, InvalidRootKeychainSourceError, MissingEncryptedKeychainError, @@ -30,7 +29,8 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { throw new Error('expected deriveKeyWithSeedBip32 to return a private key'); } const softDerivedPrv = softDerivedNode.key.toBase58(); - const hardened = deriveSafeChildHardenedFromXprv(prv, '123'); + const childPath = buildSafeBip44Path(0, 'secp256k1Multisig', 123); + const hardened = deriveSafeChildHardenedFromXprv(prv, childPath); const passphrase = 'test-passphrase'; const rootKeyId = 'root-key-id'; @@ -104,33 +104,6 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { }); } - describe('safeDerivation', function () { - it('builds the hardened path from the sequential mint index', function () { - getSafeHardenedDerivationPath(123).should.eql("m/123'"); - getSafeHardenedDerivationPath('0').should.eql("m/0'"); - }); - - it('rejects a non-integer index', function () { - (() => getSafeHardenedDerivationPath('abc')).should.throw(/Invalid safe derivation index/); - (() => getSafeHardenedDerivationPath('')).should.throw(/Invalid safe derivation index/); - (() => getSafeHardenedDerivationPath('1e2')).should.throw(/Invalid safe derivation index/); - }); - - it('hardened-derives a child that differs from soft deriveKeyWithSeed', function () { - hardened.derivationPath.should.eql("m/123'"); - hardened.prv.should.not.eql(softDerivedPrv); - }); - - it('parses the hardened derivation path as m/ primed', function () { - parseDerivedFromParentWithHardenedPath("m/0'").should.eql(0); - parseDerivedFromParentWithHardenedPath("m/123'").should.eql(123); - (() => parseDerivedFromParentWithHardenedPath('m/0')).should.throw(/derivedFromParentWithHardenedPath/); - (() => parseDerivedFromParentWithHardenedPath('m/999999/a/b')).should.throw(/derivedFromParentWithHardenedPath/); - (() => parseDerivedFromParentWithHardenedPath("not-a-path'")).should.throw(/derivedFromParentWithHardenedPath/); - (() => parseDerivedFromParentWithHardenedPath("m/0''")).should.throw(/derivedFromParentWithHardenedPath/); - }); - }); - describe('fetchRootKeychainForSafeChild', function () { it('throws when child keychain has no parent', async function () { await fetchRootKeychainForSafeChild(mockBaseCoin.keychains(), { @@ -256,7 +229,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: hardened.pub, type: 'independent', parent: rootKeyId, - derivedFromParentWithPath: "m/123'", + derivedFromParentWithPath: childPath, }, walletPassphrase: passphrase, }); @@ -307,36 +280,38 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: hardened.pub, type: 'independent', parent: rootKeyId, - derivedFromParentWithPath: "m/0'", + derivedFromParentWithPath: "m/44'/0'/1'/0'", }, walletPassphrase: passphrase, }) .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); }); - it('rejects a malformed derivedFromParentWithPath', async function () { - const wallet = makeWallet({ safe: 'safe-id-1' }); - keychainsGetStub.resolves({ - id: rootKeyId, - source: 'user', - encryptedPrv: `enc:${prv}`, - type: 'independent', - pub: 'root-pub', + for (const storedPath of ["not-a-path'", "m/123'", 'm/44/0/1/123']) { + it(`rejects a non-BIP44 derivedFromParentWithPath ${JSON.stringify(storedPath)}`, async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: hardened.pub, + type: 'independent', + parent: rootKeyId, + derivedFromParentWithPath: storedPath, + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(/derivedFromParentWithPath/); }); - - await wallet - .getUserPrv({ - keychain: { - id: 'child-key', - pub: hardened.pub, - type: 'independent', - parent: rootKeyId, - derivedFromParentWithPath: "not-a-path'", - }, - walletPassphrase: passphrase, - }) - .should.be.rejectedWith(/derivedFromParentWithHardenedPath/); - }); + } it('fails closed for TSS safe owner instead of returning the root prv', async function () { const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); @@ -395,7 +370,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: 'xpub-wrong-registered-key', type: 'independent', parent: rootKeyId, - derivedFromParentWithPath: "m/123'", + derivedFromParentWithPath: childPath, }, walletPassphrase: passphrase, }) diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts index 098dd22c55a..63636c40788 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts @@ -12,6 +12,7 @@ import { SafeDerivedPublicKeyMismatchError, SafeShareNotImplementedError, Wallet, + buildSafeBip44Path, deriveSafeChildHardenedFromXprv, } from '../../../../src'; import { BaseCoin } from '../../../../src/bitgo/baseCoin'; @@ -23,7 +24,8 @@ require('should-sinon'); describe('Safe wallet spend sharing', function () { const prv = 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; - const hardened = deriveSafeChildHardenedFromXprv(prv, '123'); + const childPath = buildSafeBip44Path(0, 'secp256k1Multisig', 123); + const hardened = deriveSafeChildHardenedFromXprv(prv, childPath); const passphrase = 'test-passphrase'; const rootKeyId = 'root-key-id'; const SHAREE_PUB = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; @@ -50,7 +52,7 @@ describe('Safe wallet spend sharing', function () { pub: hardened.pub, type: 'independent' as const, parent: rootKeyId, - derivedFromParentWithPath: "m/123'", + derivedFromParentWithPath: childPath, }); const publicOnlyKeychain = (id: string) => ({ id, pub: 'pub-' + id, type: 'independent' as const }); const rootKeychain = { @@ -189,7 +191,7 @@ describe('Safe wallet spend sharing', function () { id === rootKeyId ? rootKeychain : id === 'user-key' - ? { ...childKeychain(id), pub: 'wrong-child-pub', derivedFromParentWithPath: "m/123'" } + ? { ...childKeychain(id), pub: 'wrong-child-pub' } : publicOnlyKeychain(id) ) ); diff --git a/modules/sdk-lib-safes/src/safeDerivation.ts b/modules/sdk-lib-safes/src/safeDerivation.ts index c406358309a..e1b997eff1f 100644 --- a/modules/sdk-lib-safes/src/safeDerivation.ts +++ b/modules/sdk-lib-safes/src/safeDerivation.ts @@ -3,8 +3,10 @@ * * @experimental Shared safe child derivation for mint, sign, and recovery. * - * User child: hardened `m/'` from the sequential `safe.derivationIndex[slot]`. - * Backup / BitGo children are soft-derived at `m/` client-side for recovery. + * User child (every slot): hardened `m/44'/'/'/'`. + * Multisig co-signers (backup / BitGo): the soft twin `m/44///`. + * `` is statics `bip44CoinType`, `` is statics `SAFE_ROOT_SLOT_ORDINALS[slot]`, + * and `` is the safe's per-(slot, coinType) counter. * * Do not use `derivedFromParentWithSeed` / `deriveKeyWithSeed` (`m/999999/a/b`) — * that is the custody hashed path and cannot reproduce a safe child. @@ -12,72 +14,143 @@ import * as t from 'io-ts'; import * as nacl from 'tweetnacl'; import { bip32, BIP32Interface } from '@bitgo/utxo-lib'; -import { MAX_BIP32_INDEX } from '@bitgo/statics'; +import { coins, MAX_BIP32_INDEX, SAFE_ROOT_SLOT_ORDINALS } from '@bitgo/statics'; +import type { RootKeyType } from '@bitgo/public-types'; import { Ed25519KeyDeriver } from './ed25519KeyDeriver'; -import { decodeWithCodec } from './codecs'; import { decodeEd25519StrKeySecretSeed, encodeEd25519StrKeyPublicKey } from './ed25519Pub'; -export const DERIVED_FROM_PARENT_WITH_HARDENED_PATH = /^m\/(\d+)'$/; +const HARDENED_BIP44_PATH = /^m\/44'\/(\d+)'\/(\d+)'\/(\d+)'$/; +const SOFT_BIP44_PATH = /^m\/44\/(\d+)\/(\d+)\/(\d+)$/; +const SAFE_SLOT_ORDINALS: readonly number[] = Object.values(SAFE_ROOT_SLOT_ORDINALS); -export function parseSafeDerivationIndex(index: string | number): number { - let idx: number; - if (typeof index === 'number') { - idx = index; - } else if (/^\d+$/.test(index)) { - idx = Number(index); - } else { - throw new Error(`Invalid safe derivation index '${index}': expected a non-negative integer`); - } - if (!Number.isInteger(idx) || idx < 0 || idx > MAX_BIP32_INDEX) { - throw new Error(`Invalid safe derivation index '${index}': expected a non-negative integer`); - } - return idx; +export interface SafeBip44PathSegments { + coinType: number; + slotOrdinal: number; + account: number; } -export function getSafeHardenedDerivationPath(index: string | number): string { - return `m/${parseSafeDerivationIndex(index)}'`; +/** + * Canonical decimal only: `044` and `44` name one key under two strings, so string-equality + * path checks (the server's unique index) would treat one child as two. + */ +function parseBip32Index(segment: string): number | undefined { + const index = Number(segment); + return Number.isInteger(index) && index <= MAX_BIP32_INDEX && String(index) === segment ? index : undefined; +} + +function parseBip44Segments(match: RegExpExecArray | null): SafeBip44PathSegments | undefined { + if (!match) { + return undefined; + } + const coinType = parseBip32Index(match[1]); + const slotOrdinal = parseBip32Index(match[2]); + const account = parseBip32Index(match[3]); + if (coinType === undefined || slotOrdinal === undefined || account === undefined) { + return undefined; + } + if (!SAFE_SLOT_ORDINALS.includes(slotOrdinal)) { + return undefined; + } + return { coinType, slotOrdinal, account }; } export interface DerivedFromParentWithHardenedPathBrand { readonly DerivedFromParentWithHardenedPath: unique symbol; } +export interface DerivedFromParentWithSoftPathBrand { + readonly DerivedFromParentWithSoftPath: unique symbol; +} + +/** Safe user-child path `m/44'/'/'/'`; also every MPC child. */ export type DerivedFromParentWithHardenedPath = t.Branded; +/** Multisig co-signer path `m/44///`, the soft twin of a user-child path. */ +export type DerivedFromParentWithSoftPath = t.Branded; + +export function isDerivedFromParentWithHardenedPath(s: string): s is DerivedFromParentWithHardenedPath { + return parseBip44Segments(HARDENED_BIP44_PATH.exec(s)) !== undefined; +} + +export function isDerivedFromParentWithSoftPath(s: string): s is DerivedFromParentWithSoftPath { + return parseBip44Segments(SOFT_BIP44_PATH.exec(s)) !== undefined; +} + export const DerivedFromParentWithHardenedPath = t.brand( t.string, - (s): s is DerivedFromParentWithHardenedPath => parseDerivedFromParentWithHardenedPathIndex(s) !== undefined, + (s): s is DerivedFromParentWithHardenedPath => isDerivedFromParentWithHardenedPath(s), 'DerivedFromParentWithHardenedPath' ); -function parseDerivedFromParentWithHardenedPathIndex(path: string): number | undefined { - const match = DERIVED_FROM_PARENT_WITH_HARDENED_PATH.exec(path); - if (!match) { - return undefined; +export const DerivedFromParentWithSoftPath = t.brand( + t.string, + (s): s is DerivedFromParentWithSoftPath => isDerivedFromParentWithSoftPath(s), + 'DerivedFromParentWithSoftPath' +); + +/** + * Reads the numeric segments out of a user/MPC child path. Rejects anything else, including a + * cast soft path: a hardened derive ceremony must never run at, or register, a soft path. + */ +export function parseSafeBip44Path(path: DerivedFromParentWithHardenedPath): SafeBip44PathSegments { + const segments = parseBip44Segments(HARDENED_BIP44_PATH.exec(path)); + if (segments === undefined) { + throw new Error(`Invalid safe BIP44 path '${path}'`); } - try { - return parseSafeDerivationIndex(match[1]); - } catch { - return undefined; + return segments; +} + +/** The `` segment for a coin. OFC and fiat coins have none and cannot mint a safe child. */ +export function getSafeBip44CoinType(coin: string): number { + const coinType = coins.get(coin).bip44CoinType; + if (coinType === undefined) { + throw new Error(`Coin '${coin}' has no BIP44 coin type and cannot derive a safe child`); } + return coinType; } -export function parseDerivedFromParentWithHardenedPath(path: string): number { - const validated = decodeWithCodec(DerivedFromParentWithHardenedPath, path, 'derivedFromParentWithHardenedPath'); - const index = parseDerivedFromParentWithHardenedPathIndex(validated); - if (index === undefined) { - throw new Error(`Invalid derivedFromParentWithHardenedPath '${path}': expected m/'`); +/** + * Builds the user-child path `m/44'/'/'/'`. `coinType` comes from statics + * (or the derivation-index response) and `account` from the safe's per-(slot, coinType) counter. + */ +export function buildSafeBip44Path( + coinType: number, + slot: RootKeyType, + account: number +): DerivedFromParentWithHardenedPath { + const path = `m/44'/${coinType}'/${SAFE_ROOT_SLOT_ORDINALS[slot]}'/${account}'`; + if (!isDerivedFromParentWithHardenedPath(path)) { + throw new Error(`Invalid safe BIP44 path segments: coinType=${coinType}, slot=${slot}, account=${account}`); + } + return path; +} + +/** Strips hardening from every level: the co-signer path paired with a user-child path. */ +export function toSoftTwinPath(path: DerivedFromParentWithHardenedPath): DerivedFromParentWithSoftPath { + const softPath = path.replace(/'/g, ''); + if (!isDerivedFromParentWithSoftPath(softPath)) { + throw new Error(`Invalid safe BIP44 path '${path}': no soft twin`); + } + return softPath; +} + +/** + * The type alone is not enough: a cast path with a soft level on a user child would let the + * parent xpub plus the child prv recover the root. + */ +function assertHardenedPath(path: string): asserts path is DerivedFromParentWithHardenedPath { + if (!isDerivedFromParentWithHardenedPath(path)) { + throw new Error(`Invalid safe child path '${path}': expected m/44'/'/'/'`); } - return index; } export interface SafeHardenedChildKey { prv: string; pub: string; - derivationPath: string; + derivationPath: DerivedFromParentWithHardenedPath; } -function childFromNode(child: BIP32Interface, derivationPath: string): SafeHardenedChildKey { +function childFromNode(child: BIP32Interface, derivationPath: DerivedFromParentWithHardenedPath): SafeHardenedChildKey { if (!child.privateKey) { throw new Error(`Failed to derive hardened safe child at ${derivationPath}`); } @@ -88,16 +161,21 @@ function childFromNode(child: BIP32Interface, derivationPath: string): SafeHarde }; } -export function deriveSafeChildHardenedFromXprv(rootXprv: string, index: string | number): SafeHardenedChildKey { - const idx = parseSafeDerivationIndex(index); - const derivationPath = getSafeHardenedDerivationPath(idx); - return childFromNode(bip32.fromBase58(rootXprv).deriveHardened(idx), derivationPath); +export function deriveSafeChildHardenedFromXprv( + rootXprv: string, + path: DerivedFromParentWithHardenedPath +): SafeHardenedChildKey { + assertHardenedPath(path); + return childFromNode(bip32.fromBase58(rootXprv).derivePath(path), path); } /** Re-derive and assert both results match before the child is registered. */ -export function deriveAndSelfCheckSafeChildHardened(rootXprv: string, index: string | number): SafeHardenedChildKey { - const first = deriveSafeChildHardenedFromXprv(rootXprv, index); - const second = deriveSafeChildHardenedFromXprv(rootXprv, index); +export function deriveAndSelfCheckSafeChildHardened( + rootXprv: string, + path: DerivedFromParentWithHardenedPath +): SafeHardenedChildKey { + const first = deriveSafeChildHardenedFromXprv(rootXprv, path); + const second = deriveSafeChildHardenedFromXprv(rootXprv, path); if (first.pub !== second.pub || first.prv !== second.prv) { throw new Error(`Safe child self-check failed at ${first.derivationPath}: derivation was not deterministic`); } @@ -107,7 +185,7 @@ export function deriveAndSelfCheckSafeChildHardened(rootXprv: string, index: str /** * SLIP-0010 hardened derivation of a safe user child from an ed25519 root secret seed. * - * `rootPrv` is a Stellar StrKey `S…` secret seed (what slot-④ `ed25519Multisig` user roots store). + * `rootPrv` is a Stellar StrKey `S…` secret seed (what slot-② `ed25519Multisig` user roots store). * The derivation reuses the existing SLIP-0010 implementation {@link Ed25519KeyDeriver.derivePath} * (hardened-only CKDPriv over the `ed25519 seed` HMAC master), then expands the resulting 32-byte * child seed into a keypair with `nacl`. The child `pub` is a bare StrKey `G…`; the `prv` is the @@ -116,16 +194,52 @@ export function deriveAndSelfCheckSafeChildHardened(rootXprv: string, index: str * Unlike the secp256k1 path, ed25519 hardened derivation needs no chain code — the composite * `pub‖chainCode` form is only for SOFT co-signer derivation and is not used here. */ -export function deriveSafeChildEd25519Hardened(rootPrv: string, index: string | number): SafeHardenedChildKey { - const idx = parseSafeDerivationIndex(index); - const derivationPath = getSafeHardenedDerivationPath(idx); +export function deriveSafeChildEd25519Hardened( + rootPrv: string, + path: DerivedFromParentWithHardenedPath +): SafeHardenedChildKey { + assertHardenedPath(path); const rawSeedHex = decodeEd25519StrKeySecretSeed(rootPrv).toString('hex'); - const childSeed = Ed25519KeyDeriver.derivePath(derivationPath, rawSeedHex).key; + const childSeed = Ed25519KeyDeriver.derivePath(path, rawSeedHex).key; const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from(childSeed)); const pub = encodeEd25519StrKeyPublicKey(Buffer.from(keyPair.publicKey)); return { prv: Buffer.from(childSeed).toString('hex'), pub, - derivationPath, + derivationPath: path, + }; +} + +export interface SafeSecp256k1ChildTriplet { + user: { prv: string; pub: string }; + backup: { prv: string; pub: string }; + bitgo: { pub: string }; +} + +/** + * Derives the slot-① (secp256k1Multisig) child triplet from the keycard roots: the user child at + * the hardened `path`, the backup and BitGo children at its soft twin. + */ +export function deriveSafeChildTriplet( + roots: { user: string; backup: string; bitgo: string }, + path: DerivedFromParentWithHardenedPath +): SafeSecp256k1ChildTriplet { + assertHardenedPath(path); + const userRoot = bip32.fromBase58(roots.user); + const backupRoot = bip32.fromBase58(roots.backup); + const bitgoRoot = bip32.fromBase58(roots.bitgo); + if (userRoot.isNeutered() || backupRoot.isNeutered()) { + throw new Error('deriveSafeChildTriplet: user and backup roots must be private (xprv)'); + } + if (!bitgoRoot.isNeutered()) { + throw new Error('deriveSafeChildTriplet: bitgo root must be public (xpub)'); + } + const softPath = toSoftTwinPath(path); + const user = userRoot.derivePath(path); + const backup = backupRoot.derivePath(softPath); + return { + user: { prv: user.toBase58(), pub: user.neutered().toBase58() }, + backup: { prv: backup.toBase58(), pub: backup.neutered().toBase58() }, + bitgo: { pub: bitgoRoot.derivePath(softPath).toBase58() }, }; } diff --git a/modules/sdk-lib-safes/test/unit/safeDerivation.ts b/modules/sdk-lib-safes/test/unit/safeDerivation.ts index d621afc4de6..a2a959ffb49 100644 --- a/modules/sdk-lib-safes/test/unit/safeDerivation.ts +++ b/modules/sdk-lib-safes/test/unit/safeDerivation.ts @@ -1,111 +1,228 @@ import 'should'; import { bip32 } from '@bitgo/utxo-lib'; import { + buildSafeBip44Path, deriveAndSelfCheckSafeChildHardened, deriveSafeChildEd25519Hardened, deriveSafeChildHardenedFromXprv, - getSafeHardenedDerivationPath, - parseDerivedFromParentWithHardenedPath, - parseSafeDerivationIndex, + deriveSafeChildTriplet, + DerivedFromParentWithHardenedPath, + DerivedFromParentWithSoftPath, + getSafeBip44CoinType, + isDerivedFromParentWithHardenedPath, + isDerivedFromParentWithSoftPath, + parseSafeBip44Path, + toSoftTwinPath, } from '../../src'; -// BIP32 test vector 1 (public): seed 000102...0f → master → m/0'. The child pub is the published -// vector; the prv is asserted structurally (round-trips to the pub) rather than hardcoded. +// BIP32 test vector 1 seed. The m/44'/0'/1'/0' pub below was cross-checked against chained +// deriveHardened(44).deriveHardened(0).deriveHardened(1).deriveHardened(0). const BIP32_VECTOR_1_SEED = '000102030405060708090a0b0c0d0e0f'; -const BIP32_VECTOR_1_M0_PUB = - 'xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw'; +const BIP32_VECTOR_1_BIP44_PUB = + 'xpub6FAmCnN5quzdacVrq9uYbn68tEPZkScW6G2iDYRYsT6fGWqLKt5S1CfZdyppz139P8DJxpoRZMjTRZZfqD3FutJTd538WYrSxs77srFyGnJ'; -// 32-byte synthetic root seed. The StrKey spelling was generated with stellar-sdk; the pinned -// SLIP-0010 outputs below match sdk-core's safeDerivation test vectors. +// 32-byte synthetic root seed (StrKey generated with stellar-sdk). The deriver reproduces the +// published SLIP-0010 ed25519 vector 1 at the 5-level m/0'/1'/2'/2'/1000000000'. const ROOT_SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; +const asHardened = (path: string) => path as DerivedFromParentWithHardenedPath; + describe('safeDerivation', function () { - describe('parseSafeDerivationIndex', function () { - it('accepts numbers and digit strings', function () { - parseSafeDerivationIndex(0).should.equal(0); - parseSafeDerivationIndex(7).should.equal(7); - parseSafeDerivationIndex('42').should.equal(42); - parseSafeDerivationIndex(0x7fffffff).should.equal(0x7fffffff); + describe('path brands', function () { + const cases: { path: string; soft: boolean; hardened: boolean }[] = [ + { path: "m/44'/60'/3'/0'", soft: false, hardened: true }, + { path: "m/44'/0'/1'/2147483647'", soft: false, hardened: true }, + { path: "m/44'/2147483647'/4'/0'", soft: false, hardened: true }, + { path: 'm/44/60/3/0', soft: true, hardened: false }, + { path: 'm/44/0/1/2147483647', soft: true, hardened: false }, + // single level (the retired scheme) + { path: "m/0'", soft: false, hardened: false }, + { path: 'm/0', soft: false, hardened: false }, + // wrong depth + { path: "m/44'/60'/3'", soft: false, hardened: false }, + { path: "m/44'/60'/3'/0'/0'", soft: false, hardened: false }, + { path: 'm/44/60/3/0/0', soft: false, hardened: false }, + // wrong purpose + { path: "m/45'/60'/3'/0'", soft: false, hardened: false }, + { path: "m/0'/60'/3'/0'", soft: false, hardened: false }, + { path: 'm/45/60/3/0', soft: false, hardened: false }, + // slot outside the statics ordinals + { path: "m/44'/60'/0'/0'", soft: false, hardened: false }, + { path: "m/44'/60'/5'/0'", soft: false, hardened: false }, + { path: 'm/44/60/5/0', soft: false, hardened: false }, + // mixed flags + { path: "m/44/0'/1'/0'", soft: false, hardened: false }, + { path: "m/44'/60'/3'/0", soft: false, hardened: false }, + { path: "m/44'/60/3'/0'", soft: false, hardened: false }, + // non-canonical (leading zeros alias the canonical path) + { path: "m/44'/060'/3'/0'", soft: false, hardened: false }, + { path: 'm/44/60/3/00', soft: false, hardened: false }, + // index at or above 2^31 + { path: "m/44'/2147483648'/3'/0'", soft: false, hardened: false }, + { path: 'm/44/60/3/2147483648', soft: false, hardened: false }, + // malformed + { path: '', soft: false, hardened: false }, + { path: 'm', soft: false, hardened: false }, + { path: 'm//0', soft: false, hardened: false }, + { path: 'M/44/60/3/0', soft: false, hardened: false }, + { path: 'm/44h/60h/3h/0h', soft: false, hardened: false }, + { path: "m/44''/60'/3'/0'", soft: false, hardened: false }, + { path: 'm/44/-1/3/0', soft: false, hardened: false }, + { path: 'm/44/1.5/3/0', soft: false, hardened: false }, + { path: "m/44'/60'/3'/0'\n", soft: false, hardened: false }, + ]; + + for (const { path, soft, hardened } of cases) { + it(`${JSON.stringify(path)}: soft=${soft}, hardened=${hardened}`, function () { + isDerivedFromParentWithSoftPath(path).should.equal(soft); + isDerivedFromParentWithHardenedPath(path).should.equal(hardened); + DerivedFromParentWithSoftPath.decode(path)._tag.should.equal(soft ? 'Right' : 'Left'); + DerivedFromParentWithHardenedPath.decode(path)._tag.should.equal(hardened ? 'Right' : 'Left'); + }); + } + + it('rejects non-string inputs', function () { + DerivedFromParentWithSoftPath.decode(0)._tag.should.equal('Left'); + DerivedFromParentWithHardenedPath.decode(undefined)._tag.should.equal('Left'); }); + }); - it('rejects negatives, non-integers, and out-of-range values', function () { - (() => parseSafeDerivationIndex(-1)).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex(1.5)).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex(0x80000000)).should.throw(/Invalid safe derivation index/); + describe('getSafeBip44CoinType', function () { + it('resolves the coin family value; testnets and tokens inherit', function () { + getSafeBip44CoinType('btc').should.equal(0); + getSafeBip44CoinType('tbtc').should.equal(0); + getSafeBip44CoinType('eth').should.equal(60); + getSafeBip44CoinType('usdc').should.equal(60); }); - it('rejects non-numeric strings', function () { - (() => parseSafeDerivationIndex("0'")).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex('m/0')).should.throw(/Invalid safe derivation index/); - (() => parseSafeDerivationIndex('')).should.throw(/Invalid safe derivation index/); + it('rejects coins that cannot derive a safe child', function () { + (() => getSafeBip44CoinType('ofcbtc')).should.throw(/has no BIP44 coin type/); + (() => getSafeBip44CoinType('not-a-coin')).should.throw(); }); }); - describe('getSafeHardenedDerivationPath', function () { - it('formats m/ with hardened apostrophe', function () { - getSafeHardenedDerivationPath(0).should.equal("m/0'"); - getSafeHardenedDerivationPath('7').should.equal("m/7'"); - getSafeHardenedDerivationPath('007').should.equal("m/7'"); + describe('buildSafeBip44Path', function () { + it("builds m/44'/'/'/' with the statics slot ordinal", function () { + buildSafeBip44Path(60, 'ecdsaMpc', 0).should.equal("m/44'/60'/3'/0'"); + buildSafeBip44Path(0, 'secp256k1Multisig', 1).should.equal("m/44'/0'/1'/1'"); + buildSafeBip44Path(148, 'ed25519Multisig', 7).should.equal("m/44'/148'/2'/7'"); + buildSafeBip44Path(501, 'eddsaMpc', 0x7fffffff).should.equal("m/44'/501'/4'/2147483647'"); + }); + + it('rejects a coinType or account that is not a BIP32 index', function () { + for (const bad of [-1, 1.5, Number.NaN, Infinity, 0x80000000, 1e21]) { + (() => buildSafeBip44Path(bad, 'ecdsaMpc', 0)).should.throw(/Invalid safe BIP44 path segments/); + (() => buildSafeBip44Path(60, 'ecdsaMpc', bad)).should.throw(/Invalid safe BIP44 path segments/); + } }); }); - describe('parseDerivedFromParentWithHardenedPath', function () { - it('parses m/ primed paths', function () { - parseDerivedFromParentWithHardenedPath("m/0'").should.equal(0); - parseDerivedFromParentWithHardenedPath("m/123'").should.equal(123); + describe('parseSafeBip44Path / toSoftTwinPath', function () { + it('reads the segments back from a hardened path', function () { + parseSafeBip44Path(buildSafeBip44Path(60, 'ecdsaMpc', 9)).should.eql({ + coinType: 60, + slotOrdinal: 3, + account: 9, + }); + }); + + it('strips hardening from every level', function () { + const soft = toSoftTwinPath(buildSafeBip44Path(60, 'ecdsaMpc', 0)); + soft.should.equal('m/44/60/3/0'); + isDerivedFromParentWithSoftPath(soft).should.be.true(); }); - it('rejects unhardened and malformed paths', function () { - (() => parseDerivedFromParentWithHardenedPath('m/0')).should.throw(/derivedFromParentWithHardenedPath/); - (() => parseDerivedFromParentWithHardenedPath('m/999999/a/b')).should.throw(/derivedFromParentWithHardenedPath/); - (() => parseDerivedFromParentWithHardenedPath("not-a-path'")).should.throw(/derivedFromParentWithHardenedPath/); - (() => parseDerivedFromParentWithHardenedPath("m/0''")).should.throw(/derivedFromParentWithHardenedPath/); + it('rejects cast single-level and soft paths', function () { + (() => parseSafeBip44Path(asHardened("m/0'"))).should.throw(/Invalid safe BIP44 path/); + (() => parseSafeBip44Path(asHardened('m/44/60/3/0'))).should.throw(/Invalid safe BIP44 path/); }); }); describe('deriveSafeChildHardenedFromXprv', function () { - it('derives m/0 from the BIP32 vector 1 master', function () { - const root = bip32.fromSeed(Buffer.from(BIP32_VECTOR_1_SEED, 'hex')); - const child = deriveSafeChildHardenedFromXprv(root.toBase58(), 0); - child.derivationPath.should.equal("m/0'"); - child.pub.should.equal(BIP32_VECTOR_1_M0_PUB); + const root = bip32.fromSeed(Buffer.from(BIP32_VECTOR_1_SEED, 'hex')); + const path = buildSafeBip44Path(0, 'secp256k1Multisig', 0); + + it("derives m/44'/0'/1'/0' from the BIP32 vector 1 master", function () { + const child = deriveSafeChildHardenedFromXprv(root.toBase58(), path); + child.derivationPath.should.equal("m/44'/0'/1'/0'"); + child.pub.should.equal(BIP32_VECTOR_1_BIP44_PUB); bip32.fromBase58(child.prv).neutered().toBase58().should.equal(child.pub); }); it('self-check returns the same deterministic child', function () { - const root = bip32.fromSeed(Buffer.from(BIP32_VECTOR_1_SEED, 'hex')); - const child = deriveAndSelfCheckSafeChildHardened(root.toBase58(), 0); - child.derivationPath.should.equal("m/0'"); - child.pub.should.equal(BIP32_VECTOR_1_M0_PUB); - bip32.fromBase58(child.prv).neutered().toBase58().should.equal(child.pub); + deriveAndSelfCheckSafeChildHardened(root.toBase58(), path).pub.should.equal(BIP32_VECTOR_1_BIP44_PUB); + }); + + it('rejects cast soft, single-level, and mixed paths', function () { + for (const bad of ['m/44/0/1/0', "m/0'", "m/44/0'/1'/0'"]) { + (() => deriveSafeChildHardenedFromXprv(root.toBase58(), asHardened(bad))).should.throw( + /Invalid safe child path/ + ); + } + }); + + it('rejects a non-master root', function () { + const child = root.deriveHardened(0).toBase58(); + (() => deriveSafeChildHardenedFromXprv(child, path)).should.throw(/Expected master/); }); }); describe('deriveSafeChildEd25519Hardened', function () { - it('derives m/0 to the pinned SLIP-0010 vector', function () { - deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, 0).should.eql({ - prv: 'b127eb5092011c085345c8ce0bfeda6064f9e1249e29cc238c1d64bf2e587ce7', - pub: 'GCTZR46FPFAMYN734SQB4NCNBI44M4DSNM5RJPCDLOMAOFPEUVUXPK7R', - derivationPath: "m/0'", + it("derives m/44'/148'/2'/0' to the pinned SLIP-0010 output", function () { + deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, buildSafeBip44Path(148, 'ed25519Multisig', 0)).should.eql({ + prv: '855e8d991b48037b3e0161fd57f1643044878d6afe0db0243719b5201e2e4f69', + pub: 'GDD3VVFKM64URLG6PO2UFWQXJH67N5UDOV5HZ23M5FJJT5G4LUO7RJWG', + derivationPath: "m/44'/148'/2'/0'", }); }); - it('derives m/7 to the pinned SLIP-0010 vector', function () { - deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, '7').should.eql({ - prv: 'd54701e221cf51e9e208a7c59e3fe3e4cfbb6b91fd3f35ce092a471c35228217', - pub: 'GC2Y5EU2XA22SOSCRSZRNDEY3UAA4OJSLZ5NQUFUZDBQSLMVCIZCBHIN', - derivationPath: "m/7'", + it("derives m/44'/148'/2'/7' to the pinned SLIP-0010 output", function () { + deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, buildSafeBip44Path(148, 'ed25519Multisig', 7)).should.eql({ + prv: '3790123d377fec4805f52cbd0aa6e4f5277ed2ef75dbdbc0bf723370d10c4a82', + pub: 'GBQ52JFLZ3BEAF3XL3CZKYJ4H67OLMXZ5MNWNMRI57HR7ZPK4PK7UEEY', + derivationPath: "m/44'/148'/2'/7'", }); }); it('rejects a malformed root seed', function () { - (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY.slice(0, 55), 0)).should.throw( - /Invalid ed25519 StrKey secret seed/ + (() => + deriveSafeChildEd25519Hardened( + ROOT_SEED_STRKEY.slice(0, 55), + buildSafeBip44Path(148, 'ed25519Multisig', 0) + )).should.throw(/Invalid ed25519 StrKey secret seed/); + }); + + it('rejects a cast non-BIP44 path', function () { + (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, asHardened("m/7'"))).should.throw( + /Invalid safe child path/ ); }); + }); + + describe('deriveSafeChildTriplet', function () { + const userRoot = bip32.fromSeed(Buffer.from(BIP32_VECTOR_1_SEED, 'hex')); + const backupRoot = bip32.fromSeed(Buffer.alloc(16, 1)); + const bitgoRoot = bip32.fromSeed(Buffer.alloc(16, 2)); + const roots = { user: userRoot.toBase58(), backup: backupRoot.toBase58(), bitgo: bitgoRoot.neutered().toBase58() }; + const path = buildSafeBip44Path(0, 'secp256k1Multisig', 3); + + it('derives the user child hardened and the co-signers at the soft twin', function () { + const triplet = deriveSafeChildTriplet(roots, path); + triplet.user.pub.should.equal(deriveSafeChildHardenedFromXprv(roots.user, path).pub); + // co-signers must be derivable from the root xpubs alone, so the server can verify them + triplet.backup.pub.should.equal(backupRoot.neutered().derivePath('m/44/0/1/3').toBase58()); + triplet.bitgo.pub.should.equal(bitgoRoot.neutered().derivePath('m/44/0/1/3').toBase58()); + bip32.fromBase58(triplet.backup.prv).neutered().toBase58().should.equal(triplet.backup.pub); + }); - it('rejects an invalid index', function () { - (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, -1)).should.throw(/Invalid safe derivation index/); + it('rejects public user/backup roots and a private bitgo root', function () { + (() => deriveSafeChildTriplet({ ...roots, user: userRoot.neutered().toBase58() }, path)).should.throw( + /must be private/ + ); + (() => deriveSafeChildTriplet({ ...roots, backup: backupRoot.neutered().toBase58() }, path)).should.throw( + /must be private/ + ); + (() => deriveSafeChildTriplet({ ...roots, bitgo: bitgoRoot.toBase58() }, path)).should.throw(/must be public/); }); }); });