Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ describe('V2 Wallet:', function () {
prv,
coldDerivationSeed: '123',
};
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
});

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

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

it('should return the prv provided for TSS SMC', async () => {
Expand Down Expand Up @@ -408,7 +408,7 @@ describe('V2 Wallet:', function () {
prv,
keychain,
};
wallet.getUserPrv(userPrvOptions).should.eql(prv);
(await wallet.getUserPrv(userPrvOptions)).should.eql(prv);
});
});

Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface Keychain {
reducedEncryptedPrv?: string;
derivationPath?: string;
derivedFromParentWithSeed?: string;
/** Safe root key id this child key was derived from (WCN-1172). */
parent?: string;
commonPub?: string;
commonKeychain?: string;
keyShares?: ApiKeyShare[];
Expand Down
12 changes: 11 additions & 1 deletion modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../pendingApproval';
import { RequestTracer, RequestType } from '../utils';
import { IWallet } from '../wallet';
import { isSafeChildPublicOnlyKeychain } from '../wallet/safeKeychain';
import { BuildParams } from '../wallet/BuildParams';
import { IRequestTracer } from '../../api';
import BaseTssUtils from '../utils/tss/baseTSSUtils';
Expand Down Expand Up @@ -254,7 +255,16 @@ export class PendingApproval implements IPendingApproval {
throw new Error('txRequestId not found');
}

const decryptedPrv = await this.wallet.getPrv({ walletPassphrase });
const childUserKeychain = (
await this.wallet.baseCoin.keychains().getKeysForSigning({ wallet: this.wallet, reqId })
)[0];

const decryptedPrv = isSafeChildPublicOnlyKeychain(this.wallet.safeId(), childUserKeychain)
? await this.wallet.getUserPrv({
keychain: childUserKeychain,
walletPassphrase,
})
: await this.wallet.getPrv({ walletPassphrase });
const txRequest = await this.tssUtils!.recreateTxRequest(txRequestId, decryptedPrv, reqId);
if (txRequest.apiVersion === 'lite') {
if (!txRequest.unsignedTxs || txRequest.unsignedTxs.length === 0) {
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/safe/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './iSafe';
export * from './iSafes';
export * from './safe';
export * from './safeDerivation';
export * from './safes';
42 changes: 42 additions & 0 deletions modules/sdk-core/src/bitgo/safe/safeDerivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @prettier
*
* Shared safe child derivation for mint and sign.
* Path: m/999999'/<index>' where index is the mint allocation stored on the
* child key as derivedFromParentWithSeed.
*
* Soft deriveKeyWithSeed (m/999999/a/b) must not be used for safe children —
* it cannot reproduce a hardened key.
*/
import { bip32 } from '@bitgo/utxo-lib';

/** BIP32 purpose for safe wallet derivation (hardened). */
export const SAFE_DERIVATION_PURPOSE = 999999;

export function getSafeHardenedDerivationPath(index: string | number): string {
const idx = typeof index === 'number' ? String(index) : index;
if (!/^\d+$/.test(idx)) {
throw new Error(`Invalid safe derivation index '${index}': expected a non-negative integer`);
}
return `m/${SAFE_DERIVATION_PURPOSE}'/${idx}'`;
}

export interface SafeHardenedChildKey {
prv: string;
pub: string;
derivationPath: string;
}

/** Hardened BIP32 derive for secp256k1 multisig from a root xprv and mint index. */
export function deriveSafeChildHardenedFromXprv(rootXprv: string, index: string | number): SafeHardenedChildKey {
const derivationPath = getSafeHardenedDerivationPath(index);
const child = bip32.fromBase58(rootXprv).derivePath(derivationPath);
if (!child.privateKey) {
throw new Error(`Failed to derive hardened safe child at ${derivationPath}`);
}
return {
prv: child.toBase58(),
pub: child.neutered().toBase58(),
derivationPath,
};
}
3 changes: 3 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,8 @@ export interface WalletData {
evmKeyRingReferenceWalletId?: string;
isParent?: boolean;
enabledChildChains?: string[];
/** Set on child wallets that belong to a safe. */
safeId?: string;
/**
* @deprecated Read from `coinSpecific.userKeySigningRequired` instead. Retained
* temporarily as a fallback while the field migrates from the top level to the OFC
Expand Down Expand Up @@ -1185,6 +1187,7 @@ export interface IWallet {
subType(): SubWalletType | undefined;
multisigType(): 'onchain' | 'tss';
multisigTypeVersion(): 'MPCv2' | undefined;
safeId(): string | undefined;
label(): string;
keyIds(): string[];
receiveAddress(): string | undefined;
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './iWallet';
export * from './iWallets';
export * from './safeKeychain';
export * from './wallet';
export * from './wallets';
133 changes: 133 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/safeKeychain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @prettier
*/
import { BitGoBase } from '../bitgoBase';
import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain';
import { deriveSafeChildHardenedFromXprv } from '../safe/safeDerivation';
import { IncorrectPasswordError } from '../errors';

export class InvalidRootKeychainSourceError extends Error {
constructor(id: string, source: string | undefined) {
super(
`Root keychain ${id} has source '${source ?? 'unknown'}'; expected 'user'. ` +
`Using a backup or BitGo root would fail at signing.`
);
this.name = 'InvalidRootKeychainSourceError';
}
}

/** Thrown when hardened derivation does not match the registered child public key. */
export class SafeDerivedPublicKeyMismatchError extends Error {
constructor(walletId: string, expectedPub: string, derivedPub: string) {
super(
`Safe wallet ${walletId}: derived child public key does not match the registered user key. ` +
`Expected ${expectedPub}, got ${derivedPub}.`
);
this.name = 'SafeDerivedPublicKeyMismatchError';
}
}

/** Thrown when owner signing is not implemented for this safe slot (TSS, ed25519 multisig, …). */
export class SafeOwnerSigningNotImplementedError extends Error {
constructor(walletId: string, detail: string) {
super(`Safe wallet ${walletId}: ${detail}`);
this.name = 'SafeOwnerSigningNotImplementedError';
}
}

/** ed25519 onchain multisig (slot ④). Needs SLIP-0010, not secp256k1 BIP32. */
const ED25519_ONCHAIN_FAMILIES = new Set(['algo', 'xlm', 'hbar']);

/**
* True when this is the safe minter's user key: wallet is in a safe, the key
* has a parent root, and there is no child-level encryptedPrv (sharees have one).
*/
export function isSafeChildPublicOnlyKeychain(
walletSafeId: string | undefined,
keychain: Keychain | undefined
): keychain is Keychain & { parent: string } {
return !!(walletSafeId && keychain?.parent && !keychain.encryptedPrv);
}

/**
* Fetch the root user keychain for a safe child key.
* Requires `source === 'user'` so a misconfigured parent fails early.
*/
export async function fetchRootKeychainForSafeChild(
keychains: IKeychains,
childKeychain: Keychain
): Promise<KeychainWithEncryptedPrv> {
if (!childKeychain.parent) {
throw new Error('childKeychain.parent is required to fetch the root keychain');
}
const root = await keychains.get({ id: childKeychain.parent });
if (root.source !== 'user') {
throw new InvalidRootKeychainSourceError(root.id, root.source);
}
if (!root.encryptedPrv) {
throw new Error(`root keychain ${root.id} does not have property encryptedPrv`);
}
return root as KeychainWithEncryptedPrv;
}

export interface ResolveSafeOwnerSigningPrvParams {
bitgo: BitGoBase;
keychains: IKeychains;
walletId: string;
/** Onchain secp256k1: hardened-derive and verify pub. Other slots throw. */
multisigType: string | undefined;
coinFamily: string;
childKeychain: Keychain;
walletPassphrase: string;
/** When already fetched (passphrase preflight), skip a second GET. */
rootKeychain?: KeychainWithEncryptedPrv;
}

/**
* Resolve signing material for a safe owner (child key has no encryptedPrv).
*
* Onchain secp256k1: decrypt root → hardened-derive at `derivedFromParentWithSeed` →
* verify derived pub against the registered child 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.
* Call only when `isSafeChildPublicOnlyKeychain` is true.
*/
export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise<string> {
const { bitgo, keychains, walletId, multisigType, coinFamily, childKeychain, walletPassphrase } = params;

if (multisigType !== 'onchain') {
throw new SafeOwnerSigningNotImplementedError(
walletId,
'TSS owner signing from the root keyshare is not implemented. ' +
'Returning the root private key would expose material that can derive every child in this slot.'
);
}
if (ED25519_ONCHAIN_FAMILIES.has(coinFamily)) {
throw new SafeOwnerSigningNotImplementedError(
walletId,
`ed25519 multisig owner derivation (${coinFamily}) is not implemented; BIP32 would produce the wrong child key.`
);
}

const rootKeychain = params.rootKeychain ?? (await fetchRootKeychainForSafeChild(keychains, childKeychain));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: doesn't assert rootKeychain.id === childKeychain.parent before use, the pub-match check below catches a mismatch but an explicit assertion would make the invariant self-documenting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this could be a bit redundant - am okay with adding if we strongly feel it adds value though!

const rootPrv = await decryptKeychainPrivateKey(bitgo, rootKeychain, walletPassphrase);
if (!rootPrv) {
throw new IncorrectPasswordError();
}

if (childKeychain.derivedFromParentWithSeed === undefined) {
throw new Error(`Safe wallet ${walletId}: child keychain is missing derivedFromParentWithSeed (derivation index)`);
}

const derived = deriveSafeChildHardenedFromXprv(rootPrv, childKeychain.derivedFromParentWithSeed);

if (!childKeychain.pub) {
throw new Error(`Safe wallet ${walletId}: child keychain is missing pub for pre-sign verification`);
}
if (derived.pub !== childKeychain.pub) {
throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub);
}

return derived.prv;
}
Loading
Loading