Skip to content

Commit ce61ce5

Browse files
committed
feat: mint safe wallets via hardened user-child derivation
Ticket: WCN-1203
1 parent 37f30a4 commit ce61ce5

8 files changed

Lines changed: 350 additions & 76 deletions

File tree

modules/sdk-core/src/bitgo/keychain/iKeychains.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ export interface AddKeychainOptions {
146146
originalPasscodeEncryptionCode?: string;
147147
enterprise?: string;
148148
derivedFromParentWithSeed?: any;
149+
/** Safe user-root key id this child was derived from. @experimental */
150+
parent?: string;
149151
disableKRSEmail?: boolean;
150152
provider?: string;
151153
reqId?: IRequestTracer;

modules/sdk-core/src/bitgo/keychain/keychains.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ export class Keychains implements IKeychains {
262262
'originalPasscodeEncryptionCode',
263263
'enterprise',
264264
'derivedFromParentWithSeed',
265+
'parent',
265266
'safeId',
266267
]
267268
);
@@ -290,6 +291,7 @@ export class Keychains implements IKeychains {
290291
originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode,
291292
enterprise: params.enterprise,
292293
derivedFromParentWithSeed: params.derivedFromParentWithSeed,
294+
parent: params.parent,
293295
disableKRSEmail: params.disableKRSEmail,
294296
krsSpecific: params.krsSpecific,
295297
keyShares: params.keyShares,

modules/sdk-core/src/bitgo/safe/iSafe.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ export interface FinalizeSafeOptions {
3636
*/
3737
export type WalletShareData = WalletShare;
3838

39-
// ---- per-safe operation options (bodies land in WCN-1203 / WCN-1204) ----
39+
// ---- per-safe operation options ----
4040

4141
export interface CreateSafeWalletOptions {
4242
coin: string;
4343
label: string;
44-
type?: string;
45-
multisigTypeVersion?: string;
44+
passphrase: string;
45+
type?: 'hot';
46+
/** `tss` throws until MPC mint lands. Defaults to `onchain`. */
47+
multisigType?: 'onchain' | 'tss';
4648
}
4749

4850
interface AddSafeMemberBase {

modules/sdk-core/src/bitgo/safe/safe.ts

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
* @experimental The safe client surface is experimental and may change (including breaking
55
* changes) before the public release.
66
*/
7-
import { FreezeSafeBody, SafeData, SafeShareData, SafeShareState } from '@bitgo/public-types';
7+
import * as t from 'io-ts';
8+
import { FreezeSafeBody, SafeData, SafeShareData, SafeShareState, type RootKeyType } from '@bitgo/public-types';
9+
import { KeyCurve } from '@bitgo/statics';
10+
import { IBaseCoin } from '../baseCoin';
811
import { BitGoBase } from '../bitgoBase';
9-
import { decodeWithCodec } from '../utils/codecs';
12+
import { IncorrectPasswordError } from '../errors';
13+
import { decryptKeychainPrivateKey } from '../keychain';
14+
import { boundedInt, decodeWithCodec } from '../utils/codecs';
1015
import { postWithCodec } from '../utils/postWithCodec';
1116
import { Wallet } from '../wallet';
17+
import { InvalidRootKeychainSourceError } from '../wallet/safeKeychain';
1218
import {
1319
AcceptSafeShareOptions,
1420
AddSafeMemberOptions,
@@ -17,6 +23,50 @@ import {
1723
ISafe,
1824
WalletShareData,
1925
} from './iSafe';
26+
import { deriveAndSelfCheckSafeChildHardened } from './safeDerivation';
27+
28+
const SafeRootKeySlot = t.keyof({
29+
secp256k1Multisig: null,
30+
ecdsaMpc: null,
31+
eddsaMpc: null,
32+
ed25519Multisig: null,
33+
});
34+
35+
const GetDerivationIndexResponse = t.type({
36+
slot: SafeRootKeySlot,
37+
index: boundedInt(0, 0x7fffffff, 'derivationIndex'),
38+
});
39+
40+
const CreateWalletInSafeBody = t.strict({
41+
coin: t.string,
42+
label: t.string,
43+
type: t.literal('hot'),
44+
multisigType: t.literal('onchain'),
45+
keys: t.tuple([t.string]),
46+
});
47+
48+
function onchainSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'secp256k1Multisig'> {
49+
if (coin.getDefaultMultisigType() === 'tss') {
50+
throw new Error('MPC safe wallet minting is not yet implemented; use a slot-1 onchain coin');
51+
}
52+
const curve = coin.getConfig().primaryKeyCurve;
53+
if (curve === KeyCurve.Secp256k1) {
54+
return 'secp256k1Multisig';
55+
}
56+
if (curve === KeyCurve.Ed25519) {
57+
throw new Error('ed25519 coin safe wallet minting is not yet supported');
58+
}
59+
throw new Error(`Coin '${coin.getChain()}' is not supported for safe wallet minting`);
60+
}
61+
62+
function userRootIdFromSafe(safe: SafeData, slot: RootKeyType): string | undefined {
63+
const triplet = safe.rootKeys?.hot?.[slot];
64+
if (!triplet || triplet.length !== 3) {
65+
return undefined;
66+
}
67+
const userRootId = triplet[0];
68+
return userRootId.length > 0 ? userRootId : undefined;
69+
}
2070

2171
/**
2272
* @experimental
@@ -55,11 +105,73 @@ export class Safe implements ISafe {
55105
}
56106

57107
/**
58-
* Mint a child wallet in this safe (server-side public derivation — no ceremony).
59-
* Body lands in WCN-1203.
108+
* Mint a child wallet: peek the sequential index, hardened-derive the user child,
109+
* register it public-only, then mint. Backup and BitGo children are soft-derived on the server.
60110
*/
61111
async createWallet(params: CreateSafeWalletOptions): Promise<Wallet> {
62-
throw new Error('Safe.createWallet is not yet implemented (WCN-1203)');
112+
if (params.passphrase.length === 0) {
113+
throw new Error('passphrase is required to mint a safe wallet');
114+
}
115+
if (params.type !== undefined && params.type !== 'hot') {
116+
throw new Error('Safe wallets are hot-only in v1');
117+
}
118+
if (params.multisigType === 'tss') {
119+
throw new Error('MPC safe wallet minting is not yet implemented; use multisigType "onchain"');
120+
}
121+
122+
const coin = this.bitgo.coin(params.coin);
123+
const slot = onchainSlotForCoin(coin);
124+
125+
const indexResponse = await this.bitgo.get(this.url('/derivation-index')).query({ slot }).result();
126+
const peeked = decodeWithCodec(GetDerivationIndexResponse, indexResponse, 'GetDerivationIndexResponse');
127+
if (peeked.slot !== slot) {
128+
throw new Error(`derivation-index returned slot '${peeked.slot}', expected '${slot}'`);
129+
}
130+
const { index } = peeked;
131+
132+
const userRootId = userRootIdFromSafe(this._safe, slot) ?? userRootIdFromSafe(await this.fetchSafeData(), slot);
133+
if (userRootId === undefined) {
134+
throw new Error(`Safe ${this.id()} is missing rootKeys.hot.${slot}`);
135+
}
136+
137+
const keychains = coin.keychains();
138+
const rootKeychain = await keychains.get({ id: userRootId });
139+
if (rootKeychain.source !== 'user') {
140+
throw new InvalidRootKeychainSourceError(rootKeychain.id, rootKeychain.source);
141+
}
142+
const rootPrv = await decryptKeychainPrivateKey(this.bitgo, rootKeychain, params.passphrase);
143+
if (!rootPrv) {
144+
throw new IncorrectPasswordError();
145+
}
146+
147+
const derived = deriveAndSelfCheckSafeChildHardened(rootPrv, index);
148+
149+
const child = await keychains.add({
150+
pub: derived.pub,
151+
source: 'user',
152+
keyType: 'independent',
153+
parent: userRootId,
154+
safeId: this.id(),
155+
});
156+
const childId = child.id;
157+
if (childId.length === 0) {
158+
throw new Error('safe child key registration returned an empty id');
159+
}
160+
const keys: [string] = [childId];
161+
162+
const response = await postWithCodec(this.bitgo, this.url('/wallets'), CreateWalletInSafeBody, {
163+
coin: params.coin,
164+
label: params.label,
165+
type: 'hot',
166+
multisigType: 'onchain',
167+
keys,
168+
}).result();
169+
return new Wallet(this.bitgo, coin, response);
170+
}
171+
172+
private async fetchSafeData(): Promise<SafeData> {
173+
const response = await this.bitgo.get(this.url()).result();
174+
return decodeWithCodec(SafeData, response, 'SafeData');
63175
}
64176

65177
/**

modules/sdk-core/src/bitgo/safe/safeDerivation.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,37 @@
22
* @prettier
33
*
44
* Shared safe child derivation for mint and sign.
5-
* Path: m/999999'/<index>' where index is the mint allocation stored on the
6-
* child key as derivedFromParentWithSeed.
75
*
8-
* Soft deriveKeyWithSeed (m/999999/a/b) must not be used for safe children —
9-
* it cannot reproduce a hardened key.
6+
* User child: hardened `m/<index>'` from the sequential `safe.derivationIndex[slot]`.
7+
* Backup / BitGo children are soft-derived server-side at `m/<index>` (not here).
8+
*
9+
* Do not use `derivedFromParentWithSeed` / `deriveKeyWithSeed` (`m/999999/a/b`) —
10+
* that is the custody hashed path and cannot reproduce a safe child.
1011
*/
11-
import { bip32 } from '@bitgo/utxo-lib';
12+
import { bip32, BIP32Interface } from '@bitgo/utxo-lib';
1213

13-
/** BIP32 purpose for safe wallet derivation (hardened). */
14-
export const SAFE_DERIVATION_PURPOSE = 999999;
14+
const MAX_BIP32_INDEX = 0x7fffffff;
1515

16-
export function getSafeHardenedDerivationPath(index: string | number): string {
17-
const idx = typeof index === 'number' ? String(index) : index;
18-
if (!/^\d+$/.test(idx)) {
16+
/** Sign-time scan cap (wallet cap plus abandoned mint increments). */
17+
export const MAX_SAFE_CHILD_INDEX_SCAN = 4096;
18+
19+
export function parseSafeDerivationIndex(index: string | number): number {
20+
let idx: number;
21+
if (typeof index === 'number') {
22+
idx = index;
23+
} else if (/^\d+$/.test(index)) {
24+
idx = Number(index);
25+
} else {
26+
throw new Error(`Invalid safe derivation index '${index}': expected a non-negative integer`);
27+
}
28+
if (!Number.isInteger(idx) || idx < 0 || idx > MAX_BIP32_INDEX) {
1929
throw new Error(`Invalid safe derivation index '${index}': expected a non-negative integer`);
2030
}
21-
return `m/${SAFE_DERIVATION_PURPOSE}'/${idx}'`;
31+
return idx;
32+
}
33+
34+
export function getSafeHardenedDerivationPath(index: string | number): string {
35+
return `m/${parseSafeDerivationIndex(index)}'`;
2236
}
2337

2438
export interface SafeHardenedChildKey {
@@ -27,10 +41,7 @@ export interface SafeHardenedChildKey {
2741
derivationPath: string;
2842
}
2943

30-
/** Hardened BIP32 derive for secp256k1 multisig from a root xprv and mint index. */
31-
export function deriveSafeChildHardenedFromXprv(rootXprv: string, index: string | number): SafeHardenedChildKey {
32-
const derivationPath = getSafeHardenedDerivationPath(index);
33-
const child = bip32.fromBase58(rootXprv).derivePath(derivationPath);
44+
function childFromNode(child: BIP32Interface, derivationPath: string): SafeHardenedChildKey {
3445
if (!child.privateKey) {
3546
throw new Error(`Failed to derive hardened safe child at ${derivationPath}`);
3647
}
@@ -40,3 +51,36 @@ export function deriveSafeChildHardenedFromXprv(rootXprv: string, index: string
4051
derivationPath,
4152
};
4253
}
54+
55+
export function deriveSafeChildHardenedFromXprv(rootXprv: string, index: string | number): SafeHardenedChildKey {
56+
const idx = parseSafeDerivationIndex(index);
57+
const derivationPath = getSafeHardenedDerivationPath(idx);
58+
return childFromNode(bip32.fromBase58(rootXprv).deriveHardened(idx), derivationPath);
59+
}
60+
61+
/** Re-derive and assert both results match before the child is registered. */
62+
export function deriveAndSelfCheckSafeChildHardened(rootXprv: string, index: string | number): SafeHardenedChildKey {
63+
const first = deriveSafeChildHardenedFromXprv(rootXprv, index);
64+
const second = deriveSafeChildHardenedFromXprv(rootXprv, index);
65+
if (first.pub !== second.pub || first.prv !== second.prv) {
66+
throw new Error(`Safe child self-check failed at ${first.derivationPath}: derivation was not deterministic`);
67+
}
68+
return first;
69+
}
70+
71+
/** Walk `m/0'` … `m/<max>'` until the registered child pub matches. */
72+
export function deriveSafeChildHardenedMatchingPub(
73+
rootXprv: string,
74+
expectedPub: string,
75+
maxIndex: number = MAX_SAFE_CHILD_INDEX_SCAN
76+
): SafeHardenedChildKey {
77+
const root = bip32.fromBase58(rootXprv);
78+
const limit = parseSafeDerivationIndex(maxIndex);
79+
for (let i = 0; i <= limit; i++) {
80+
const derived = childFromNode(root.deriveHardened(i), getSafeHardenedDerivationPath(i));
81+
if (derived.pub === expectedPub) {
82+
return derived;
83+
}
84+
}
85+
throw new Error(`No hardened safe child at m/0'..m/${limit}' matched the registered public key`);
86+
}

modules/sdk-core/src/bitgo/wallet/safeKeychain.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { BitGoBase } from '../bitgoBase';
55
import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain';
6-
import { deriveSafeChildHardenedFromXprv } from '../safe/safeDerivation';
6+
import { deriveSafeChildHardenedMatchingPub } from '../safe/safeDerivation';
77
import { IncorrectPasswordError } from '../errors';
88

99
export class InvalidRootKeychainSourceError extends Error {
@@ -86,8 +86,8 @@ export interface ResolveSafeOwnerSigningPrvParams {
8686
/**
8787
* Resolve signing material for a safe owner (child key has no encryptedPrv).
8888
*
89-
* Onchain secp256k1: decrypt root → hardened-derive at `derivedFromParentWithSeed` →
90-
* verify derived pub against the registered child pub.
89+
* Onchain secp256k1: decrypt root → walk sequential `m/<n>'` children until the
90+
* registered pub matches (the mint index is not stored on the child key).
9191
* TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve.
9292
*
9393
* Do not use for wallet sharing — that must not receive root key material.
@@ -116,18 +116,15 @@ export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigning
116116
throw new IncorrectPasswordError();
117117
}
118118

119-
if (childKeychain.derivedFromParentWithSeed === undefined) {
120-
throw new Error(`Safe wallet ${walletId}: child keychain is missing derivedFromParentWithSeed (derivation index)`);
121-
}
122-
123-
const derived = deriveSafeChildHardenedFromXprv(rootPrv, childKeychain.derivedFromParentWithSeed);
124-
125119
if (!childKeychain.pub) {
126120
throw new Error(`Safe wallet ${walletId}: child keychain is missing pub for pre-sign verification`);
127121
}
128-
if (derived.pub !== childKeychain.pub) {
129-
throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub);
130-
}
131122

132-
return derived.prv;
123+
try {
124+
const derived = deriveSafeChildHardenedMatchingPub(rootPrv, childKeychain.pub);
125+
return derived.prv;
126+
} catch (e) {
127+
const detail = e instanceof Error ? e.message : String(e);
128+
throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, detail);
129+
}
133130
}

0 commit comments

Comments
 (0)