Skip to content
Draft
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
16 changes: 15 additions & 1 deletion modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,21 @@ export class PendingApproval implements IPendingApproval {
throw new Error('txRequestId not found');
}

const decryptedPrv = await this.wallet.getPrv({ walletPassphrase });
let decryptedPrv: string;
if (this.wallet.safeId()) {
// Safe owner keys have no child encryptedPrv; getPrv would fail. Use getUserPrv instead.
const childKeychains = await this.wallet.baseCoin.keychains().getKeysForSigning({ wallet: this.wallet, reqId });
const childUserKeychain = childKeychains[0];
if (!childUserKeychain) {
throw new Error('user keychain not found');
}
decryptedPrv = await this.wallet.getUserPrv({
keychain: childUserKeychain,
walletPassphrase,
});
} else {
decryptedPrv = 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';
109 changes: 109 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/safeKeychain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @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';
}
}

/**
* True for a safe owner's child key: has `parent`, no `encryptedPrv`
* (private material lives on the root). Sharees have their own `encryptedPrv`.
*/
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, encryptedPrv: root.encryptedPrv };
}

export interface ResolveSafeOwnerSigningPrvParams {
bitgo: BitGoBase;
keychains: IKeychains;
walletId: string;
/** Onchain: hardened-derive and verify pub. TSS: return decrypted root prv. */
multisigType: string | undefined;
childKeychain: Keychain;
walletPassphrase: string;
}

/**
* Resolve signing material for a safe owner (child key has no encryptedPrv).
*
* Onchain: decrypt root → hardened-derive at `derivedFromParentWithSeed` →
* verify derived pub against the registered child pub.
* TSS: decrypt and return the root prv (child share derivation is separate).
*
* 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, childKeychain, walletPassphrase } = params;

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

if (multisigType !== 'onchain') {
return rootPrv;
}

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;
}
73 changes: 56 additions & 17 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa';
import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa';
import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest';
import { buildParamKeys, BuildParams } from './BuildParams';
import {
fetchRootKeychainForSafeChild,
isSafeChildPublicOnlyKeychain,
resolveSafeOwnerSigningPrv,
} from './safeKeychain';
import {
AccelerateTransactionOptions,
AddressesByBalanceOptions,
Expand Down Expand Up @@ -378,6 +383,10 @@ export class Wallet implements IWallet {
return this._wallet.multisigTypeVersion;
}

safeId(): string | undefined {
return this._wallet.safeId;
}

subType(): SubWalletType | undefined {
return this._wallet.subType;
}
Expand Down Expand Up @@ -2215,7 +2224,7 @@ export class Wallet implements IWallet {
walletPassphrase,
});
const userKeychain = keychains[0];
if (!userKeychain || !userKeychain.encryptedPrv) {
if (!userKeychain || (!userKeychain.encryptedPrv && !isSafeChildPublicOnlyKeychain(this.safeId(), userKeychain))) {
throw new Error('the user keychain does not have property encryptedPrv');
}

Expand Down Expand Up @@ -2317,7 +2326,10 @@ export class Wallet implements IWallet {
walletPassphrase: params.walletPassphrase,
});
const userKeychain = keychains[0];
if (!userKeychain || !userKeychain.encryptedPrv) {
if (
!userKeychain ||
(!userKeychain.encryptedPrv && !isSafeChildPublicOnlyKeychain(this.safeId(), userKeychain))
) {
throw new Error('the user keychain does not have property encryptedPrv');
}
params.keychain = userKeychain;
Expand Down Expand Up @@ -2533,37 +2545,56 @@ export class Wallet implements IWallet {
throw new Error('prv must be a string');
}

// Auto-populate coldDerivationSeed for SMC keys that lack encryptedPrv.
// Safe owners use hardened derivation; sharees already hold a child-level encryptedPrv.
if (
params.coldDerivationSeed === undefined &&
params.keychain !== undefined &&
params.keychain.derivedFromParentWithSeed !== undefined &&
this.multisigType() === 'onchain'
this.multisigType() === 'onchain' &&
!params.keychain.encryptedPrv &&
!this.safeId()
) {
params.coldDerivationSeed = params.keychain.derivedFromParentWithSeed;
}

if (userPrv && params.coldDerivationSeed) {
const derivation = this.baseCoin.deriveKeyWithSeed({
key: userPrv,
seed: params.coldDerivationSeed,
});
userPrv = derivation.key;
} else if (!userPrv) {
if (!userPrv) {
if (!userKeychain || typeof userKeychain !== 'object') {
throw new Error('keychain must be an object');
}
const userEncryptedPrv = userKeychain.encryptedPrv;
if (!userEncryptedPrv) {
throw new Error('keychain does not have property encryptedPrv');
}
if (!params.walletPassphrase) {
throw new Error('walletPassphrase property missing');
}

// Safe owner: resolve signing prv from the root (child keychain stays in params for TSS).
if (isSafeChildPublicOnlyKeychain(this.safeId(), userKeychain)) {
return resolveSafeOwnerSigningPrv({
bitgo: this.bitgo,
keychains: this.baseCoin.keychains(),
walletId: this.id(),
multisigType: this.multisigType(),
childKeychain: userKeychain,
walletPassphrase: params.walletPassphrase,
});
}

if (!userKeychain.encryptedPrv) {
throw new Error('keychain does not have property encryptedPrv');
}
userPrv = await decryptKeychainPrivateKey(this.bitgo, userKeychain, params.walletPassphrase);
if (!userPrv) {
throw new IncorrectPasswordError();
}
}

// Soft seed derivation for SMC (and any explicit coldDerivationSeed).
if (userPrv && params.coldDerivationSeed) {
const derivation = this.baseCoin.deriveKeyWithSeed({
key: userPrv,
seed: params.coldDerivationSeed,
});
userPrv = derivation.key;
}
return userPrv;
}

Expand Down Expand Up @@ -5368,9 +5399,17 @@ export class Wallet implements IWallet {
// Doing a sanity check for password here to avoid doing further work if we know it's wrong
// we ignore this check with if customSigningFunction is provided
// which means that the user is handling the signing in external signing mode
if (!customSigningFunction && keychains?.[0]?.encryptedPrv && walletPassphrase) {
if (!(await decryptKeychainPrivateKey(this.bitgo, keychains[0], walletPassphrase))) {
throw new IncorrectPasswordError();
if (!customSigningFunction && walletPassphrase) {
const userKeychain = keychains?.[0];
let keychainToValidate = userKeychain;
// Owner child keys have no encryptedPrv; check the passphrase against the root key instead.
if (isSafeChildPublicOnlyKeychain(this.safeId(), userKeychain)) {
keychainToValidate = await fetchRootKeychainForSafeChild(this.baseCoin.keychains(), userKeychain);
}
if (keychainToValidate?.encryptedPrv) {
if (!(await decryptKeychainPrivateKey(this.bitgo, keychainToValidate, walletPassphrase))) {
throw new IncorrectPasswordError();
}
}
}
return keychains;
Expand Down
Loading
Loading