diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 81013ade88..e8723e9a16 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 8af222cc30..67307e37c8 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..75e93bab47 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -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) { 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..6d3ae7865f --- /dev/null +++ b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts @@ -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 { + 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 { + 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; +} diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 23931e6821..985310ba84 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, @@ -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; } @@ -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'); } @@ -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; @@ -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; } @@ -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; 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..19eabdbe07 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts @@ -0,0 +1,589 @@ +/** + * @prettier + */ +import 'should'; +import * as sinon from 'sinon'; +import { + deriveSafeChildHardenedFromXprv, + fetchRootKeychainForSafeChild, + getSafeHardenedDerivationPath, + IncorrectPasswordError, + InvalidRootKeychainSourceError, + MissingEncryptedKeychainError, + PendingApproval, + RequestTracer, + SafeDerivedPublicKeyMismatchError, + 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('returns root prv for TSS safe owner without BIP32 derive or pub check', async function () { + const wallet = makeWallet({ safeId: 'safe-id-1', multisigType: 'tss' }); + keychainsGetStub.resolves({ + id: rootKeyId, + source: 'user', + encryptedPrv: `enc:${prv}`, + type: 'tss', + pub: 'root-pub', + }); + + const result = await wallet.getUserPrv({ + keychain: { + id: 'child-key', + pub: 'unrelated-child-pub', + type: 'tss', + parent: rootKeyId, + derivedFromParentWithSeed: '123', + commonKeychain: 'ck', + }, + walletPassphrase: passphrase, + }); + + result.should.eql(prv); + 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(); + }); + }); + + 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 hot-wallet branch 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]), + }); + mockBaseCoin.presignTransaction = sinon.stub().callsFake(async (params: any) => params); + mockBaseCoin.getMPCAlgorithm = sinon.stub().returns('eddsa'); + const getUserPrvStub = sinon.stub(Wallet.prototype, 'getUserPrv').resolves('derived-prv'); + const signTssStub = sinon.stub(Wallet.prototype as any, 'signTransactionTss').resolves({ txHex: 'signed' }); + + const result = await wallet.signTransaction({ + walletPassphrase: passphrase, + txPrebuild: { txRequestId: 'tx-req' }, + }); + + result.should.eql({ txHex: 'signed' }); + getUserPrvStub.calledOnce.should.be.true(); + signTssStub.calledOnce.should.be.true(); + }); + }); +}); + +describe('WCN-1200 recreateAndSignTSSTransaction safe path', function () { + afterEach(function () { + sinon.restore(); + }); + + it('uses getUserPrv for safe child wallets 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 recreateTxRequest = sinon.stub().resolves({ + apiVersion: 'lite', + txRequestId: 'tx-req', + unsignedTxs: [{ serializedTxHex: 'deadbeef', signableHex: 'ab', derivationPath: 'm/0' }], + transactions: [], + }); + + const wallet: any = { + safeId: () => 'safe-id-1', + getUserPrv, + getPrv, + baseCoin: { + keychains: () => ({ getKeysForSigning }), + supportsTss: () => true, + getMPCAlgorithm: () => 'eddsa', + }, + multisigTypeVersion: () => undefined, + }; + + 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 }; + + 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(); + }); +});