Skip to content

Commit 82522a1

Browse files
committed
feat(sdk-core): add root-fetch detour in getUserPrv for safe child wallets
Ticket: WCN-1200
1 parent b87841a commit 82522a1

10 files changed

Lines changed: 933 additions & 20 deletions

File tree

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ describe('V2 Wallet:', function () {
353353
prv,
354354
coldDerivationSeed: '123',
355355
};
356-
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
356+
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
357357
});
358358

359359
it('should use the user keychain derivedFromParentWithSeed as the cold derivation seed if none is provided', async () => {
@@ -366,7 +366,7 @@ describe('V2 Wallet:', function () {
366366
type: 'independent',
367367
},
368368
};
369-
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
369+
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
370370
});
371371

372372
it('should prefer the explicit cold derivation seed to the user keychain derivedFromParentWithSeed', async () => {
@@ -380,7 +380,7 @@ describe('V2 Wallet:', function () {
380380
type: 'independent',
381381
},
382382
};
383-
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
383+
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
384384
});
385385

386386
it('should return the prv provided for TSS SMC', async () => {
@@ -408,7 +408,7 @@ describe('V2 Wallet:', function () {
408408
prv,
409409
keychain,
410410
};
411-
wallet.getUserPrv(userPrvOptions).should.eql(prv);
411+
(await wallet.getUserPrv(userPrvOptions)).should.eql(prv);
412412
});
413413
});
414414

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export interface Keychain {
4949
reducedEncryptedPrv?: string;
5050
derivationPath?: string;
5151
derivedFromParentWithSeed?: string;
52+
/** Safe root key id this child key was derived from (WCN-1172). */
53+
parent?: string;
5254
commonPub?: string;
5355
commonKeychain?: string;
5456
keyShares?: ApiKeyShare[];

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '../pendingApproval';
1818
import { RequestTracer, RequestType } from '../utils';
1919
import { IWallet } from '../wallet';
20+
import { isSafeChildPublicOnlyKeychain } from '../wallet/safeKeychain';
2021
import { BuildParams } from '../wallet/BuildParams';
2122
import { IRequestTracer } from '../../api';
2223
import BaseTssUtils from '../utils/tss/baseTSSUtils';
@@ -254,7 +255,16 @@ export class PendingApproval implements IPendingApproval {
254255
throw new Error('txRequestId not found');
255256
}
256257

257-
const decryptedPrv = await this.wallet.getPrv({ walletPassphrase });
258+
const childUserKeychain = (
259+
await this.wallet.baseCoin.keychains().getKeysForSigning({ wallet: this.wallet, reqId })
260+
)[0];
261+
262+
const decryptedPrv = isSafeChildPublicOnlyKeychain(this.wallet.safeId(), childUserKeychain)
263+
? await this.wallet.getUserPrv({
264+
keychain: childUserKeychain,
265+
walletPassphrase,
266+
})
267+
: await this.wallet.getPrv({ walletPassphrase });
258268
const txRequest = await this.tssUtils!.recreateTxRequest(txRequestId, decryptedPrv, reqId);
259269
if (txRequest.apiVersion === 'lite') {
260270
if (!txRequest.unsignedTxs || txRequest.unsignedTxs.length === 0) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './iSafe';
22
export * from './iSafes';
33
export * from './safe';
4+
export * from './safeDerivation';
45
export * from './safes';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @prettier
3+
*
4+
* 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.
7+
*
8+
* Soft deriveKeyWithSeed (m/999999/a/b) must not be used for safe children —
9+
* it cannot reproduce a hardened key.
10+
*/
11+
import { bip32 } from '@bitgo/utxo-lib';
12+
13+
/** BIP32 purpose for safe wallet derivation (hardened). */
14+
export const SAFE_DERIVATION_PURPOSE = 999999;
15+
16+
export function getSafeHardenedDerivationPath(index: string | number): string {
17+
const idx = typeof index === 'number' ? String(index) : index;
18+
if (!/^\d+$/.test(idx)) {
19+
throw new Error(`Invalid safe derivation index '${index}': expected a non-negative integer`);
20+
}
21+
return `m/${SAFE_DERIVATION_PURPOSE}'/${idx}'`;
22+
}
23+
24+
export interface SafeHardenedChildKey {
25+
prv: string;
26+
pub: string;
27+
derivationPath: string;
28+
}
29+
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);
34+
if (!child.privateKey) {
35+
throw new Error(`Failed to derive hardened safe child at ${derivationPath}`);
36+
}
37+
return {
38+
prv: child.toBase58(),
39+
pub: child.neutered().toBase58(),
40+
derivationPath,
41+
};
42+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,8 @@ export interface WalletData {
10161016
evmKeyRingReferenceWalletId?: string;
10171017
isParent?: boolean;
10181018
enabledChildChains?: string[];
1019+
/** Set on child wallets that belong to a safe. */
1020+
safeId?: string;
10191021
/**
10201022
* @deprecated Read from `coinSpecific.userKeySigningRequired` instead. Retained
10211023
* temporarily as a fallback while the field migrates from the top level to the OFC
@@ -1185,6 +1187,7 @@ export interface IWallet {
11851187
subType(): SubWalletType | undefined;
11861188
multisigType(): 'onchain' | 'tss';
11871189
multisigTypeVersion(): 'MPCv2' | undefined;
1190+
safeId(): string | undefined;
11881191
label(): string;
11891192
keyIds(): string[];
11901193
receiveAddress(): string | undefined;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './iWallet';
22
export * from './iWallets';
3+
export * from './safeKeychain';
34
export * from './wallet';
45
export * from './wallets';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @prettier
3+
*/
4+
import { BitGoBase } from '../bitgoBase';
5+
import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain';
6+
import { deriveSafeChildHardenedFromXprv } from '../safe/safeDerivation';
7+
import { IncorrectPasswordError } from '../errors';
8+
9+
export class InvalidRootKeychainSourceError extends Error {
10+
constructor(id: string, source: string | undefined) {
11+
super(
12+
`Root keychain ${id} has source '${source ?? 'unknown'}'; expected 'user'. ` +
13+
`Using a backup or BitGo root would fail at signing.`
14+
);
15+
this.name = 'InvalidRootKeychainSourceError';
16+
}
17+
}
18+
19+
/** Thrown when hardened derivation does not match the registered child public key. */
20+
export class SafeDerivedPublicKeyMismatchError extends Error {
21+
constructor(walletId: string, expectedPub: string, derivedPub: string) {
22+
super(
23+
`Safe wallet ${walletId}: derived child public key does not match the registered user key. ` +
24+
`Expected ${expectedPub}, got ${derivedPub}.`
25+
);
26+
this.name = 'SafeDerivedPublicKeyMismatchError';
27+
}
28+
}
29+
30+
/** Thrown when owner signing is not implemented for this safe slot (TSS, ed25519 multisig, …). */
31+
export class SafeOwnerSigningNotImplementedError extends Error {
32+
constructor(walletId: string, detail: string) {
33+
super(`Safe wallet ${walletId}: ${detail}`);
34+
this.name = 'SafeOwnerSigningNotImplementedError';
35+
}
36+
}
37+
38+
/** ed25519 onchain multisig (slot ④). Needs SLIP-0010, not secp256k1 BIP32. */
39+
const ED25519_ONCHAIN_FAMILIES = new Set(['algo', 'xlm', 'hbar']);
40+
41+
/**
42+
* True when this is the safe minter's user key: wallet is in a safe, the key
43+
* has a parent root, and there is no child-level encryptedPrv (sharees have one).
44+
*/
45+
export function isSafeChildPublicOnlyKeychain(
46+
walletSafeId: string | undefined,
47+
keychain: Keychain | undefined
48+
): keychain is Keychain & { parent: string } {
49+
return !!(walletSafeId && keychain?.parent && !keychain.encryptedPrv);
50+
}
51+
52+
/**
53+
* Fetch the root user keychain for a safe child key.
54+
* Requires `source === 'user'` so a misconfigured parent fails early.
55+
*/
56+
export async function fetchRootKeychainForSafeChild(
57+
keychains: IKeychains,
58+
childKeychain: Keychain
59+
): Promise<KeychainWithEncryptedPrv> {
60+
if (!childKeychain.parent) {
61+
throw new Error('childKeychain.parent is required to fetch the root keychain');
62+
}
63+
const root = await keychains.get({ id: childKeychain.parent });
64+
if (root.source !== 'user') {
65+
throw new InvalidRootKeychainSourceError(root.id, root.source);
66+
}
67+
if (!root.encryptedPrv) {
68+
throw new Error(`root keychain ${root.id} does not have property encryptedPrv`);
69+
}
70+
return root as KeychainWithEncryptedPrv;
71+
}
72+
73+
export interface ResolveSafeOwnerSigningPrvParams {
74+
bitgo: BitGoBase;
75+
keychains: IKeychains;
76+
walletId: string;
77+
/** Onchain secp256k1: hardened-derive and verify pub. Other slots throw. */
78+
multisigType: string | undefined;
79+
coinFamily: string;
80+
childKeychain: Keychain;
81+
walletPassphrase: string;
82+
/** When already fetched (passphrase preflight), skip a second GET. */
83+
rootKeychain?: KeychainWithEncryptedPrv;
84+
}
85+
86+
/**
87+
* Resolve signing material for a safe owner (child key has no encryptedPrv).
88+
*
89+
* Onchain secp256k1: decrypt root → hardened-derive at `derivedFromParentWithSeed` →
90+
* verify derived pub against the registered child pub.
91+
* TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve.
92+
*
93+
* Do not use for wallet sharing — that must not receive root key material.
94+
* Call only when `isSafeChildPublicOnlyKeychain` is true.
95+
*/
96+
export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise<string> {
97+
const { bitgo, keychains, walletId, multisigType, coinFamily, childKeychain, walletPassphrase } = params;
98+
99+
if (multisigType !== 'onchain') {
100+
throw new SafeOwnerSigningNotImplementedError(
101+
walletId,
102+
'TSS owner signing from the root keyshare is not implemented. ' +
103+
'Returning the root private key would expose material that can derive every child in this slot.'
104+
);
105+
}
106+
if (ED25519_ONCHAIN_FAMILIES.has(coinFamily)) {
107+
throw new SafeOwnerSigningNotImplementedError(
108+
walletId,
109+
`ed25519 multisig owner derivation (${coinFamily}) is not implemented; BIP32 would produce the wrong child key.`
110+
);
111+
}
112+
113+
const rootKeychain = params.rootKeychain ?? (await fetchRootKeychainForSafeChild(keychains, childKeychain));
114+
const rootPrv = await decryptKeychainPrivateKey(bitgo, rootKeychain, walletPassphrase);
115+
if (!rootPrv) {
116+
throw new IncorrectPasswordError();
117+
}
118+
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+
125+
if (!childKeychain.pub) {
126+
throw new Error(`Safe wallet ${walletId}: child keychain is missing pub for pre-sign verification`);
127+
}
128+
if (derived.pub !== childKeychain.pub) {
129+
throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub);
130+
}
131+
132+
return derived.prv;
133+
}

0 commit comments

Comments
 (0)