Skip to content
Merged
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
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 @@ -146,6 +146,8 @@ export interface AddKeychainOptions {
originalPasscodeEncryptionCode?: string;
enterprise?: string;
derivedFromParentWithSeed?: any;
/** Safe user-root key id this child was derived from. @experimental */
parent?: string;
disableKRSEmail?: boolean;
provider?: string;
reqId?: IRequestTracer;
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export class Keychains implements IKeychains {
'originalPasscodeEncryptionCode',
'enterprise',
'derivedFromParentWithSeed',
'parent',
'safeId',
]
);
Expand Down Expand Up @@ -290,6 +291,7 @@ export class Keychains implements IKeychains {
originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode,
enterprise: params.enterprise,
derivedFromParentWithSeed: params.derivedFromParentWithSeed,
parent: params.parent,
disableKRSEmail: params.disableKRSEmail,
krsSpecific: params.krsSpecific,
keyShares: params.keyShares,
Expand Down
8 changes: 5 additions & 3 deletions modules/sdk-core/src/bitgo/safe/iSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ export interface FinalizeSafeOptions {
*/
export type WalletShareData = WalletShare;

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

export interface CreateSafeWalletOptions {
coin: string;
label: string;
type?: string;
multisigTypeVersion?: string;
passphrase: string;
type?: 'hot';
/** `tss` throws until MPC mint lands. Defaults to `onchain`. */
multisigType?: 'onchain' | 'tss';
}

interface AddSafeMemberBase {
Expand Down
122 changes: 117 additions & 5 deletions modules/sdk-core/src/bitgo/safe/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
* @experimental The safe client surface is experimental and may change (including breaking
* changes) before the public release.
*/
import { FreezeSafeBody, SafeData, SafeShareData, SafeShareState } from '@bitgo/public-types';
import * as t from 'io-ts';
import { FreezeSafeBody, SafeData, SafeShareData, SafeShareState, type RootKeyType } from '@bitgo/public-types';
import { KeyCurve } from '@bitgo/statics';
import { IBaseCoin } from '../baseCoin';
import { BitGoBase } from '../bitgoBase';
import { decodeWithCodec } from '../utils/codecs';
import { IncorrectPasswordError } from '../errors';
import { decryptKeychainPrivateKey } from '../keychain';
import { boundedInt, decodeWithCodec } from '../utils/codecs';
import { postWithCodec } from '../utils/postWithCodec';
import { Wallet } from '../wallet';
import { InvalidRootKeychainSourceError } from '../wallet/safeKeychain';
import {
AcceptSafeShareOptions,
AddSafeMemberOptions,
Expand All @@ -17,6 +23,50 @@ import {
ISafe,
WalletShareData,
} from './iSafe';
import { deriveAndSelfCheckSafeChildHardened } from './safeDerivation';

const SafeRootKeySlot = t.keyof({
secp256k1Multisig: null,
ecdsaMpc: null,
eddsaMpc: null,
ed25519Multisig: null,
});

const GetDerivationIndexResponse = t.type({
slot: SafeRootKeySlot,
index: boundedInt(0, 0x7fffffff, 'derivationIndex'),
});

const CreateWalletInSafeBody = t.strict({
coin: t.string,
label: t.string,
type: t.literal('hot'),
multisigType: t.literal('onchain'),
keys: t.tuple([t.string]),
});

function onchainSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'secp256k1Multisig'> {
if (coin.getDefaultMultisigType() === 'tss') {
throw new Error('MPC safe wallet minting is not yet implemented; use a slot-1 onchain coin');
}
const curve = coin.getConfig().primaryKeyCurve;
if (curve === KeyCurve.Secp256k1) {
return 'secp256k1Multisig';
}
if (curve === KeyCurve.Ed25519) {
throw new Error('ed25519 coin safe wallet minting is not yet supported');
}
throw new Error(`Coin '${coin.getChain()}' is not supported for safe wallet minting`);
}

function userRootIdFromSafe(safe: SafeData, slot: RootKeyType): string | undefined {
const triplet = safe.rootKeys?.hot?.[slot];
if (!triplet || triplet.length !== 3) {
return undefined;
}
const userRootId = triplet[0];
return userRootId.length > 0 ? userRootId : undefined;
}

/**
* @experimental
Expand Down Expand Up @@ -55,11 +105,73 @@ export class Safe implements ISafe {
}

/**
* Mint a child wallet in this safe (server-side public derivation — no ceremony).
* Body lands in WCN-1203.
* Mint a child wallet: peek the sequential index, hardened-derive the user child,
* register it public-only, then mint. Backup and BitGo children are soft-derived on the server.
*/
async createWallet(params: CreateSafeWalletOptions): Promise<Wallet> {
throw new Error('Safe.createWallet is not yet implemented (WCN-1203)');
if (params.passphrase.length === 0) {
throw new Error('passphrase is required to mint a safe wallet');
}
if (params.type !== undefined && params.type !== 'hot') {
throw new Error('Safe wallets are hot-only in v1');
}
if (params.multisigType === 'tss') {
throw new Error('MPC safe wallet minting is not yet implemented; use multisigType "onchain"');
}

const coin = this.bitgo.coin(params.coin);
const slot = onchainSlotForCoin(coin);

const indexResponse = await this.bitgo.get(this.url('/derivation-index')).query({ slot }).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;

const userRootId = userRootIdFromSafe(this._safe, slot) ?? userRootIdFromSafe(await this.fetchSafeData(), slot);
if (userRootId === undefined) {
throw new Error(`Safe ${this.id()} is missing rootKeys.hot.${slot}`);
}

const keychains = coin.keychains();
const rootKeychain = await keychains.get({ id: userRootId });
if (rootKeychain.source !== 'user') {
throw new InvalidRootKeychainSourceError(rootKeychain.id, rootKeychain.source);
}
const rootPrv = await decryptKeychainPrivateKey(this.bitgo, rootKeychain, params.passphrase);
if (!rootPrv) {
throw new IncorrectPasswordError();
}

const derived = deriveAndSelfCheckSafeChildHardened(rootPrv, index);

const child = await keychains.add({
pub: derived.pub,
source: 'user',
keyType: 'independent',
parent: userRootId,
safeId: this.id(),
});
const childId = child.id;
if (childId.length === 0) {
throw new Error('safe child key registration returned an empty id');
}
const keys: [string] = [childId];

const response = await postWithCodec(this.bitgo, this.url('/wallets'), CreateWalletInSafeBody, {
coin: params.coin,
label: params.label,
type: 'hot',
multisigType: 'onchain',
keys,
}).result();
return new Wallet(this.bitgo, coin, response);
}

private async fetchSafeData(): Promise<SafeData> {
const response = await this.bitgo.get(this.url()).result();
return decodeWithCodec(SafeData, response, 'SafeData');
}

/**
Expand Down
74 changes: 59 additions & 15 deletions modules/sdk-core/src/bitgo/safe/safeDerivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,37 @@
* @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.
* User child: hardened `m/<index>'` from the sequential `safe.derivationIndex[slot]`.
* Backup / BitGo children are soft-derived server-side at `m/<index>` (not here).
*
* Do not use `derivedFromParentWithSeed` / `deriveKeyWithSeed` (`m/999999/a/b`) —
* that is the custody hashed path and cannot reproduce a safe child.
*/
import { bip32 } from '@bitgo/utxo-lib';
import { bip32, BIP32Interface } from '@bitgo/utxo-lib';

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

export function getSafeHardenedDerivationPath(index: string | number): string {
const idx = typeof index === 'number' ? String(index) : index;
if (!/^\d+$/.test(idx)) {
/** Sign-time scan cap (wallet cap plus abandoned mint increments). */
export const MAX_SAFE_CHILD_INDEX_SCAN = 4096;

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 `m/${SAFE_DERIVATION_PURPOSE}'/${idx}'`;
return idx;
}

export function getSafeHardenedDerivationPath(index: string | number): string {
return `m/${parseSafeDerivationIndex(index)}'`;
}

export interface SafeHardenedChildKey {
Expand All @@ -27,10 +41,7 @@ export interface SafeHardenedChildKey {
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);
function childFromNode(child: BIP32Interface, derivationPath: string): SafeHardenedChildKey {
if (!child.privateKey) {
throw new Error(`Failed to derive hardened safe child at ${derivationPath}`);
}
Expand All @@ -40,3 +51,36 @@ export function deriveSafeChildHardenedFromXprv(rootXprv: string, index: string
derivationPath,
};
}

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);
}

/** 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);
if (first.pub !== second.pub || first.prv !== second.prv) {
throw new Error(`Safe child self-check failed at ${first.derivationPath}: derivation was not deterministic`);
}
return first;
}

/** Walk `m/0'` … `m/<max>'` until the registered child pub matches. */
export function deriveSafeChildHardenedMatchingPub(
rootXprv: string,
expectedPub: string,
maxIndex: number = MAX_SAFE_CHILD_INDEX_SCAN
): SafeHardenedChildKey {
const root = bip32.fromBase58(rootXprv);
const limit = parseSafeDerivationIndex(maxIndex);
for (let i = 0; i <= limit; i++) {
const derived = childFromNode(root.deriveHardened(i), getSafeHardenedDerivationPath(i));
if (derived.pub === expectedPub) {
return derived;
}
}
throw new Error(`No hardened safe child at m/0'..m/${limit}' matched the registered public key`);
}
23 changes: 10 additions & 13 deletions modules/sdk-core/src/bitgo/wallet/safeKeychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { BitGoBase } from '../bitgoBase';
import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain';
import { deriveSafeChildHardenedFromXprv } from '../safe/safeDerivation';
import { deriveSafeChildHardenedMatchingPub } from '../safe/safeDerivation';
import { IncorrectPasswordError } from '../errors';

export class InvalidRootKeychainSourceError extends Error {
Expand Down Expand Up @@ -86,8 +86,8 @@ export interface ResolveSafeOwnerSigningPrvParams {
/**
* 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.
* Onchain secp256k1: decrypt root → walk sequential `m/<n>'` children until the
* registered pub matches (the mint index is not stored on the child key).
* 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.
Expand Down Expand Up @@ -116,18 +116,15 @@ export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigning
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;
try {
const derived = deriveSafeChildHardenedMatchingPub(rootPrv, childKeychain.pub);
return derived.prv;
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, detail);
}
}
Loading
Loading