diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 591ec1d38d..b86c51c49f 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -408,7 +408,7 @@ describe('V2 Wallet:', function () { prv, keychain, }; - wallet.getUserPrv(userPrvOptions).should.eql(prv); + (await wallet.getUserPrv(userPrvOptions)).should.eql(prv); }); }); diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index de5d03cdc5..56da033ec9 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -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[]; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts index 18e2fd0dd9..cb867e3c1e 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -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'; @@ -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) { diff --git a/modules/sdk-core/src/bitgo/safe/index.ts b/modules/sdk-core/src/bitgo/safe/index.ts index 5a4f621ee3..e23e9e8108 100644 --- a/modules/sdk-core/src/bitgo/safe/index.ts +++ b/modules/sdk-core/src/bitgo/safe/index.ts @@ -1,4 +1,5 @@ export * from './iSafe'; export * from './iSafes'; export * from './safe'; +export * from './safeDerivation'; export * from './safes'; diff --git a/modules/sdk-core/src/bitgo/safe/safeDerivation.ts b/modules/sdk-core/src/bitgo/safe/safeDerivation.ts new file mode 100644 index 0000000000..df699b7756 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/safeDerivation.ts @@ -0,0 +1,42 @@ +/** + * @prettier + * + * Shared safe child derivation for mint and sign. + * Path: m/999999'/' 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, + }; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index fb573a429b..3fb40b46b3 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -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 @@ -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; diff --git a/modules/sdk-core/src/bitgo/wallet/index.ts b/modules/sdk-core/src/bitgo/wallet/index.ts index bd067b353c..f734687947 100644 --- a/modules/sdk-core/src/bitgo/wallet/index.ts +++ b/modules/sdk-core/src/bitgo/wallet/index.ts @@ -1,4 +1,5 @@ export * from './iWallet'; export * from './iWallets'; +export * from './safeKeychain'; export * from './wallet'; export * from './wallets'; diff --git a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts new file mode 100644 index 0000000000..d6ef75e0b7 --- /dev/null +++ b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts @@ -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 { + 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 { + 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)); + 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; +} diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 23931e6821..30b1bd4f3d 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -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, @@ -233,6 +238,8 @@ export class Wallet implements IWallet { private _defi?: DefiVault; private readonly tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | undefined; private readonly _permissions?: string[]; + /** Root keychain from passphrase preflight; consumed by getUserPrv to avoid a second GET. */ + private validatedSafeRootKeychain?: KeychainWithEncryptedPrv; constructor(bitgo: BitGoBase, baseCoin: IBaseCoin, walletData: any) { this.bitgo = bitgo; @@ -378,6 +385,10 @@ export class Wallet implements IWallet { return this._wallet.multisigTypeVersion; } + safeId(): string | undefined { + return this._wallet.safeId; + } + subType(): SubWalletType | undefined { return this._wallet.subType; } @@ -2215,7 +2226,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'); } @@ -2317,7 +2328,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; @@ -2533,27 +2547,44 @@ 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) { + + // Safe owner: resolve signing prv from the root (child keychain stays in params for TSS). + if (isSafeChildPublicOnlyKeychain(this.safeId(), userKeychain)) { + if (!params.walletPassphrase) { + throw new Error('walletPassphrase property missing'); + } + const rootKeychain = this.validatedSafeRootKeychain; + this.validatedSafeRootKeychain = undefined; + return resolveSafeOwnerSigningPrv({ + bitgo: this.bitgo, + keychains: this.baseCoin.keychains(), + walletId: this.id(), + multisigType: this.multisigType(), + coinFamily: this.baseCoin.getFamily(), + childKeychain: userKeychain, + walletPassphrase: params.walletPassphrase, + rootKeychain, + }); + } + + if (!userKeychain.encryptedPrv) { throw new Error('keychain does not have property encryptedPrv'); } if (!params.walletPassphrase) { @@ -2563,6 +2594,12 @@ export class Wallet implements IWallet { if (!userPrv) { throw new IncorrectPasswordError(); } + } else if (userPrv && params.coldDerivationSeed) { + const derivation = this.baseCoin.deriveKeyWithSeed({ + key: userPrv, + seed: params.coldDerivationSeed, + }); + userPrv = derivation.key; } return userPrv; } @@ -5364,13 +5401,24 @@ export class Wallet implements IWallet { reqId, }: PrebuildTransactionOptions & WalletSignTransactionOptions): Promise { const keychains = await this.baseCoin.keychains().getKeysForSigning({ wallet: this, reqId }); + this.validatedSafeRootKeychain = undefined; // 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)) { + this.validatedSafeRootKeychain = await fetchRootKeychainForSafeChild(this.baseCoin.keychains(), userKeychain); + keychainToValidate = this.validatedSafeRootKeychain; + } + if (keychainToValidate?.encryptedPrv) { + if (!(await decryptKeychainPrivateKey(this.bitgo, keychainToValidate, walletPassphrase))) { + this.validatedSafeRootKeychain = undefined; + throw new IncorrectPasswordError(); + } } } return keychains; diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts new file mode 100644 index 0000000000..11609867cd --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts @@ -0,0 +1,673 @@ +/** + * @prettier + */ +import 'should'; +import * as sinon from 'sinon'; +import { + deriveSafeChildHardenedFromXprv, + fetchRootKeychainForSafeChild, + getSafeHardenedDerivationPath, + IncorrectPasswordError, + InvalidRootKeychainSourceError, + MissingEncryptedKeychainError, + PendingApproval, + RequestTracer, + SafeDerivedPublicKeyMismatchError, + SafeOwnerSigningNotImplementedError, + Wallet, +} from '../../../../src'; +import { BaseCoin } from '../../../../src/bitgo/baseCoin'; + +require('should-sinon'); + +describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { + const prv = + 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; + // Soft deriveKeyWithSeedBip32(prv, '123') — must NOT be used for safe owners. + const softDerivedPrv = + 'xprv9yoG67Td11uwjXwbV8zEmrySVXERu5FZAsLD9suBeEJbgJqANs8Yng5dEJoii7hag5JermK6PbfxgDmSzW7ewWeLmeJEkmPfmZUSLdETtHx'; + const hardened = deriveSafeChildHardenedFromXprv(prv, '123'); + const passphrase = 'test-passphrase'; + const rootKeyId = 'root-key-id'; + + let mockBitGo: any; + let mockBaseCoin: any; + let keychainsGetStub: sinon.SinonStub; + let encryptStub: sinon.SinonStub; + let decryptStub: sinon.SinonStub; + + const baseWalletData = { + id: 'wallet-id', + coin: 'tbtc', + keys: ['user-key', 'backup-key', 'bitgo-key'], + type: 'hot', + multisigType: 'onchain', + enterprise: 'ent-id', + }; + + beforeEach(function () { + keychainsGetStub = sinon.stub(); + encryptStub = sinon.stub(); + decryptStub = sinon.stub(); + + mockBitGo = { + encrypt: encryptStub, + decrypt: decryptStub, + url: sinon.stub().returns('https://test.bitgo.com/'), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + getChain: sinon.stub().returns('tbtc'), + getFamily: sinon.stub().returns('btc'), + getFullName: sinon.stub().returns('Test Bitcoin'), + keychains: sinon.stub().returns({ + get: keychainsGetStub, + getKeysForSigning: sinon.stub().resolves([]), + }), + deriveKeyWithSeed: sinon.stub().callsFake(({ key, seed }: { key: string; seed: string }) => { + if (key === prv && seed === '123') { + return { key: softDerivedPrv, derivationPath: 'm/999999/...' }; + } + return { key: `derived(${key},${seed})`, derivationPath: 'm/0' }; + }), + url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com/api/v2/tbtc${path}`), + supportsStaking: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(false), + getMPCAlgorithm: sinon.stub(), + keyIdsForSigning: sinon.stub().returns([0, 1, 2]), + }; + + decryptStub.callsFake(({ input, password }: { input: string; password: string }) => { + if (password !== passphrase) { + return null; + } + if (typeof input === 'string' && input.startsWith('enc:')) { + return input.slice(4); + } + return null; + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + function makeWallet(overrides: Record = {}): Wallet { + return new Wallet(mockBitGo, mockBaseCoin as unknown as BaseCoin, { + ...baseWalletData, + ...overrides, + }); + } + + describe('safeDerivation', function () { + it('builds the hardened path from the mint index', function () { + getSafeHardenedDerivationPath(123).should.eql("m/999999'/123'"); + getSafeHardenedDerivationPath('0').should.eql("m/999999'/0'"); + }); + + it('rejects a non-integer index', function () { + (() => getSafeHardenedDerivationPath('abc')).should.throw(/Invalid safe derivation index/); + }); + + it('hardened-derives a child that differs from soft deriveKeyWithSeed', function () { + hardened.derivationPath.should.eql("m/999999'/123'"); + hardened.prv.should.not.eql(softDerivedPrv); + hardened.prv.should.eql( + 'xprv9wMxE3idjgW7UoSodEZgYpy7aSzt32GC7j63s277VwkRbVvnkRubmFqZ4UUghHVTaSbdHZA3NM8FuwH4CoTQzaVzzUh1BwKcNYn17NczoQy' + ); + hardened.pub.should.eql( + 'xpub6AMJdZFXa44QhHXGjG6guxur8UqNSUz3Ux1efQWj4HHQUJFwHyDrK4A2ukru4QZ9PfhTYbPLBNYFL7gbdhTidSppW1aQ9QgYPT5cBFmoDEu' + ); + }); + }); + + describe('fetchRootKeychainForSafeChild', function () { + it('throws when child keychain has no parent', async function () { + await fetchRootKeychainForSafeChild(mockBaseCoin.keychains(), { + id: 'child-key', + type: 'independent', + pub: 'child-pub', + }).should.be.rejectedWith('childKeychain.parent is required to fetch the root keychain'); + keychainsGetStub.notCalled.should.be.true(); + }); + + it('throws InvalidRootKeychainSourceError when root source is backup', async function () { + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'backup', + encryptedPrv: 'enc:root', + type: 'independent', + pub: 'root-pub', + }); + + await fetchRootKeychainForSafeChild(mockBaseCoin.keychains(), { + id: 'child-key', + parent: rootKeyId, + type: 'independent', + pub: 'child-pub', + }).should.be.rejectedWith(InvalidRootKeychainSourceError); + }); + + it('returns the root keychain when source is user', async function () { + const root = { + id: rootKeyId, + source: 'user', + encryptedPrv: 'enc:root', + type: 'independent', + pub: 'root-pub', + }; + keychainsGetStub.resolves(root); + + const result = await fetchRootKeychainForSafeChild(mockBaseCoin.keychains(), { + id: 'child-key', + parent: rootKeyId, + type: 'independent', + pub: 'child-pub', + }); + result.should.eql(root); + }); + + it('throws when root keychain has no encryptedPrv', async function () { + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + type: 'independent', + pub: 'root-pub', + }); + + await fetchRootKeychainForSafeChild(mockBaseCoin.keychains(), { + id: 'child-key', + parent: rootKeyId, + type: 'independent', + pub: 'child-pub', + }).should.be.rejectedWith(/does not have property encryptedPrv/); + }); + }); + + describe('getUserPrv', function () { + it('throws when keychain has no encryptedPrv on a non-safe wallet', async function () { + const wallet = makeWallet(); + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'pub', + type: 'independent', + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith('keychain does not have property encryptedPrv'); + }); + + it('throws for a non-safe wallet even when keychain has parent', async function () { + const wallet = makeWallet(); + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'pub', + type: 'independent', + parent: rootKeyId, + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith('keychain does not have property encryptedPrv'); + keychainsGetStub.notCalled.should.be.true(); + }); + + it('throws for a safe wallet when keychain has no parent', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1' }); + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'pub', + type: 'independent', + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith('keychain does not have property encryptedPrv'); + keychainsGetStub.notCalled.should.be.true(); + }); + + it('fetches root and hardened-derives child key for safe owner', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1' }); + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + + const result = await wallet.getUserPrv({ + keychain: { + id: 'child-key', + pub: hardened.pub, + type: 'independent', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + }, + walletPassphrase: passphrase, + }); + + result.should.eql(hardened.prv); + result.should.not.eql(softDerivedPrv); + keychainsGetStub.calledOnceWithExactly({ id: rootKeyId }).should.be.true(); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + }); + + it('throws when onchain safe owner is missing derivedFromParentWithSeed', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1' }); + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: hardened.pub, + type: 'independent', + parent: rootKeyId, + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(/missing derivedFromParentWithSeed/); + }); + + it('fails closed for TSS safe owner instead of returning the root prv', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1', multisigType: 'tss' }); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'unrelated-child-pub', + type: 'tss', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + commonKeychain: 'ck', + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(SafeOwnerSigningNotImplementedError); + keychainsGetStub.notCalled.should.be.true(); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + }); + + it('fails closed for ed25519 onchain safe owner instead of BIP32-deriving', async function () { + mockBaseCoin.getFamily.returns('xlm'); + const wallet = makeWallet({ safeId: 'safe-id-1', coin: 'txlm' }); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'child-pub', + type: 'independent', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(SafeOwnerSigningNotImplementedError); + keychainsGetStub.notCalled.should.be.true(); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + }); + + it('aborts locally when derived pub does not match registered child pub', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1' }); + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'xpub-wrong-registered-key', + type: 'independent', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + }, + walletPassphrase: passphrase, + }) + .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); + }); + + it('decrypts child encryptedPrv as-is for wallet sharee (hardened child prv)', async function () { + const childPrv = 'child-level-prv'; + const wallet = makeWallet({ safeId: 'safe-id-1' }); + + const result = await wallet.getUserPrv({ + keychain: { + id: 'child-key', + pub: 'child-pub', + type: 'independent', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + encryptedPrv: `enc:${childPrv}`, + }, + walletPassphrase: passphrase, + }); + + result.should.eql(childPrv); + keychainsGetStub.notCalled.should.be.true(); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + }); + + it('does not auto-populate coldDerivationSeed when explicit prv and encryptedPrv are present', async function () { + const childPrv = 'child-level-prv'; + const wallet = makeWallet({ safeId: 'safe-id-1' }); + + const result = await wallet.getUserPrv({ + prv: childPrv, + keychain: { + id: 'child-key', + pub: 'child-pub', + type: 'independent', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + encryptedPrv: `enc:${childPrv}`, + }, + }); + + result.should.eql(childPrv); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + }); + + it('still auto-populates coldDerivationSeed for SMC with params.prv and no encryptedPrv', async function () { + const wallet = makeWallet(); + + const result = await wallet.getUserPrv({ + prv, + keychain: { + id: 'smc-key', + pub: 'smc-pub', + type: 'independent', + derivedFromParentWithSeed: '123', + }, + }); + + result.should.eql(softDerivedPrv); + mockBaseCoin.deriveKeyWithSeed.calledOnce.should.be.true(); + }); + + it('does not apply an explicit coldDerivationSeed after decrypting encryptedPrv', async function () { + const wallet = makeWallet(); + const childPrv = prv; + + const result = await wallet.getUserPrv({ + keychain: { + id: 'smc-key', + pub: 'smc-pub', + type: 'independent', + encryptedPrv: `enc:${childPrv}`, + }, + walletPassphrase: passphrase, + coldDerivationSeed: '123', + }); + + result.should.eql(childPrv); + mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); + }); + + it('throws encryptedPrv error before walletPassphrase when both are missing', async function () { + const wallet = makeWallet(); + await wallet + .getUserPrv({ + keychain: { + id: 'child-key', + pub: 'pub', + type: 'independent', + }, + }) + .should.be.rejectedWith('keychain does not have property encryptedPrv'); + }); + }); + + describe('getEncryptedUserKeychain', function () { + it('still fails for a safe owner so wallet sharing cannot obtain root material', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1' }); + keychainsGetStub.resolves({ + id: 'user-key', + pub: 'child-pub', + type: 'independent', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + }); + + await wallet.getEncryptedUserKeychain().should.be.rejectedWith(MissingEncryptedKeychainError); + keychainsGetStub.called.should.be.true(); + }); + }); + + describe('signing guards', function () { + it('getUserKeyAndSignTssTransaction allows safe child keychain without encryptedPrv', async function () { + const wallet = makeWallet({ + safeId: 'safe-id-1', + multisigType: 'tss', + type: 'hot', + }); + const childKeychain = { + id: 'child-key', + pub: 'child-pub', + type: 'tss' as const, + parent: rootKeyId, + commonKeychain: 'ck', + }; + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + mockBaseCoin.keychains.returns({ + get: keychainsGetStub, + getKeysForSigning: sinon.stub().resolves([childKeychain]), + }); + const signStub = sinon.stub(Wallet.prototype, 'signTransaction').resolves({ txHex: 'signed' } as any); + + const result = await wallet.getUserKeyAndSignTssTransaction({ + txRequestId: 'tx-req', + walletPassphrase: passphrase, + }); + + result.should.eql({ txHex: 'signed' }); + signStub.calledOnce.should.be.true(); + const signArgs = signStub.firstCall.args[0] as { keychain: typeof childKeychain }; + signArgs.keychain.should.eql(childKeychain); + }); + + it('getUserKeyAndSignTssTransaction rejects wrong passphrase early for safe child wallets', async function () { + const wallet = makeWallet({ + safeId: 'safe-id-1', + multisigType: 'tss', + type: 'hot', + }); + const childKeychain = { + id: 'child-key', + pub: 'child-pub', + type: 'tss' as const, + parent: rootKeyId, + commonKeychain: 'ck', + }; + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + mockBaseCoin.keychains.returns({ + get: keychainsGetStub, + getKeysForSigning: sinon.stub().resolves([childKeychain]), + }); + const signStub = sinon.stub(Wallet.prototype, 'signTransaction'); + + await wallet + .getUserKeyAndSignTssTransaction({ + txRequestId: 'tx-req', + walletPassphrase: 'wrong-passphrase', + }) + .should.be.rejectedWith(IncorrectPasswordError); + + signStub.notCalled.should.be.true(); + keychainsGetStub.calledOnce.should.be.true(); + }); + + it('signTransaction does not pass the root prv into TSS signing for a safe owner', async function () { + const wallet = makeWallet({ + safeId: 'safe-id-1', + multisigType: 'tss', + type: 'hot', + }); + const childKeychain = { + id: 'child-key', + pub: 'child-pub', + type: 'tss' as const, + parent: rootKeyId, + commonKeychain: 'ck', + }; + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'independent', + pub: 'root-pub', + }); + mockBaseCoin.keychains.returns({ + get: keychainsGetStub, + getKeysForSigning: sinon.stub().resolves([childKeychain]), + }); + mockBaseCoin.presignTransaction = sinon.stub().callsFake(async (params: any) => params); + mockBaseCoin.getMPCAlgorithm = sinon.stub().returns('eddsa'); + const signTssStub = sinon.stub(Wallet.prototype as any, 'signTransactionTss').resolves({ txHex: 'signed' }); + + await wallet + .signTransaction({ + walletPassphrase: passphrase, + txPrebuild: { txRequestId: 'tx-req' }, + }) + .should.be.rejectedWith(SafeOwnerSigningNotImplementedError); + signTssStub.notCalled.should.be.true(); + }); + }); +}); + +describe('WCN-1200 recreateAndSignTSSTransaction safe path', function () { + afterEach(function () { + sinon.restore(); + }); + + function makePendingApproval(wallet: any) { + const recreateTxRequest = sinon.stub().resolves({ + apiVersion: 'lite', + txRequestId: 'tx-req', + unsignedTxs: [{ serializedTxHex: 'deadbeef', signableHex: 'ab', derivationPath: 'm/0' }], + transactions: [], + }); + const pendingApproval = new PendingApproval( + {} as any, + wallet.baseCoin, + { + id: 'pa0', + txRequestId: 'tx-req', + info: { type: 'transactionRequest', transactionRequest: { recipients: [], coinSpecific: {} } }, + state: 'pending', + creator: 'test', + } as any, + wallet + ); + (pendingApproval as any).tssUtils = { recreateTxRequest }; + return { pendingApproval, recreateTxRequest }; + } + + it('uses getUserPrv for the safe owner (minter) instead of getPrv', async function () { + const childUserKeychain = { + id: 'child-key', + pub: 'child-pub', + type: 'tss', + parent: 'root-key-id', + derivedFromParentWithSeed: 'seed', + }; + const getKeysForSigning = sinon.stub().resolves([childUserKeychain]); + const getUserPrv = sinon.stub().resolves('decryptedPrv'); + const getPrv = sinon.stub().resolves('should-not-be-called'); + const wallet: any = { + safeId: () => 'safe-id-1', + getUserPrv, + getPrv, + baseCoin: { + keychains: () => ({ getKeysForSigning }), + supportsTss: () => true, + getMPCAlgorithm: () => 'eddsa', + }, + multisigTypeVersion: () => undefined, + }; + const { pendingApproval, recreateTxRequest } = makePendingApproval(wallet); + + const result = await pendingApproval.recreateAndSignTSSTransaction( + { walletPassphrase: 'pass' }, + new RequestTracer() + ); + + result.should.eql({ txHex: 'deadbeef' }); + getPrv.notCalled.should.be.true(); + getKeysForSigning.calledOnce.should.be.true(); + getUserPrv + .calledOnceWithExactly({ + keychain: childUserKeychain, + walletPassphrase: 'pass', + }) + .should.be.true(); + recreateTxRequest.calledOnce.should.be.true(); + }); + + it('uses getPrv for a safe wallet sharee (child encryptedPrv already present)', async function () { + const childUserKeychain = { + id: 'child-key', + pub: 'child-pub', + type: 'tss', + parent: 'root-key-id', + derivedFromParentWithSeed: 'seed', + encryptedPrv: 'enc:sharee-prv', + }; + const getKeysForSigning = sinon.stub().resolves([childUserKeychain]); + const getUserPrv = sinon.stub().resolves('should-not-be-called'); + const getPrv = sinon.stub().resolves('sharee-prv'); + const wallet: any = { + safeId: () => 'safe-id-1', + getUserPrv, + getPrv, + baseCoin: { + keychains: () => ({ getKeysForSigning }), + supportsTss: () => true, + getMPCAlgorithm: () => 'eddsa', + }, + multisigTypeVersion: () => undefined, + }; + const { pendingApproval, recreateTxRequest } = makePendingApproval(wallet); + + const result = await pendingApproval.recreateAndSignTSSTransaction( + { walletPassphrase: 'pass' }, + new RequestTracer() + ); + + result.should.eql({ txHex: 'deadbeef' }); + getUserPrv.notCalled.should.be.true(); + getPrv.calledOnceWithExactly({ walletPassphrase: 'pass' }).should.be.true(); + recreateTxRequest.calledOnce.should.be.true(); + }); +});