import { describe, test, expect } from 'bun:test'; import { sealSeed, generateSeed, deriveAccountXpubs, UnlockSession, verifyPassphrase, exportMnemonic, changePassphrase, } from './keys'; /** * The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than * merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the * properties below are asserted rather than assumed. * * scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the * parameter and also why this file carries a raised timeout instead of a smaller N. */ // BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce. const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const ROOT_FINGERPRINT = '73c5da0a'; const PASS = 'correct horse battery staple'; const SLOW = 60_000; describe('sealSeed / deriveAccountXpubs', () => { test( 'seals the vector and derives all four accounts', async () => { const env = await sealSeed(VECTOR, PASS); const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); expect(env.v).toBe(1); expect(fingerprint).toBe(ROOT_FINGERPRINT); for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy(); }, SLOW, ); test( 'the envelope never contains the plaintext mnemonic', async () => { const env = await sealSeed(VECTOR, PASS); expect(JSON.stringify(env)).not.toContain('abandon'); }, SLOW, ); test( 'sealing is non-deterministic but derivation is stable', async () => { // Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a // DB dump would reveal which wallets share a seed. const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)]; expect(JSON.stringify(a)).not.toBe(JSON.stringify(b)); const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')]; expect(xb.xpubs[84]).toBe(xa.xpubs[84]); }, SLOW, ); test( 'rejects a mnemonic that fails its checksum', async () => { const bad = VECTOR.replace(/about$/, 'abandon'); await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i); }, SLOW, ); test( 'rejects a passphrase under the minimum length', async () => { await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/); }, SLOW, ); test( 'testnet derives a different account under the same root', async () => { const env = await sealSeed(VECTOR, PASS); const [main, test_] = [ await deriveAccountXpubs(env, PASS, 'bitcoin'), await deriveAccountXpubs(env, PASS, 'testnet'), ]; // Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint. expect(test_.xpubs[84]).not.toBe(main.xpubs[84]); expect(test_.fingerprint).toBe(main.fingerprint); }, SLOW, ); }); /** * A throwaway wallet id per call. Every passphrase check is now rate-limited per wallet id in module * state, so tests that share one would start locking each other out after five wrong guesses. */ let idSeq = 0; const wid = () => ++idSeq; describe('passphrase handling', () => { test( 'accepts the right passphrase and rejects a wrong one', async () => { const env = await sealSeed(VECTOR, PASS); expect(await verifyPassphrase(wid(), env, PASS)).toBe(true); expect(await verifyPassphrase(wid(), env, 'not the passphrase')).toBe(false); }, SLOW, ); test( 'locks out after five wrong guesses, on export as well as unlock', async () => { const env = await sealSeed(VECTOR, PASS); const id = wid(); // The backoff used to live inside UnlockSession.unlock only, so export-seed — which returns the // words in the clear — took unlimited guesses. Exercised through exportMnemonic for that reason. for (let i = 0; i < 5; i++) { await expect(exportMnemonic(id, env, `wrong ${i}`)).rejects.toThrow(/incorrect passphrase/); } await expect(exportMnemonic(id, env, `wrong 5`)).rejects.toThrow(/too many failed attempts/); // The right passphrase is refused too — otherwise the lockout would only slow a guesser down. await expect(exportMnemonic(id, env, PASS)).rejects.toThrow(/too many failed attempts/); // And it is not folded into a bland `false` by the boolean-returning sibling. await expect(verifyPassphrase(id, env, PASS)).rejects.toThrow(/too many failed attempts/); }, SLOW, ); test( 'round-trips the mnemonic exactly', async () => { const env = await sealSeed(VECTOR, PASS); expect(await exportMnemonic(wid(), env, PASS)).toBe(VECTOR); }, SLOW, ); test( 'rotation swaps the passphrase without disturbing the seed', async () => { const env = await sealSeed(VECTOR, PASS); const next = 'an entirely different passphrase'; const rotated = await changePassphrase(wid(), env, PASS, next); expect(await verifyPassphrase(wid(), rotated, next)).toBe(true); expect(await verifyPassphrase(wid(), rotated, PASS)).toBe(false); expect(await exportMnemonic(wid(), rotated, next)).toBe(VECTOR); // The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently // start watching a different account and report a zero balance. const [before, after] = [ await deriveAccountXpubs(env, PASS, 'bitcoin'), await deriveAccountXpubs(rotated, next, 'bitcoin'), ]; expect(after.xpubs[84]).toBe(before.xpubs[84]); expect(after.fingerprint).toBe(before.fingerprint); }, SLOW, ); }); describe('UnlockSession', () => { test('holds no key material until unlocked', async () => { const s = new UnlockSession(1); expect(s.isUnlocked()).toBe(false); expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i); }); test( 'unlocks, derives the stored xpub, then relocks', async () => { const env = await sealSeed(VECTOR, PASS); const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); const s = new UnlockSession(1); await s.unlock(env, PASS, 60); expect(s.isUnlocked()).toBe(true); expect(s.secondsRemaining()).toBeGreaterThan(0); expect(s.secondsRemaining()).toBeLessThanOrEqual(60); // Proves the unlocked root is the same key the watch-only xpub came from. expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]); s.lock(); expect(s.isUnlocked()).toBe(false); expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i); }, SLOW, ); test( 'a failed unlock leaves the session locked', async () => { const env = await sealSeed(VECTOR, PASS); const s = new UnlockSession(1); await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow(); expect(s.isUnlocked()).toBe(false); }, SLOW, ); }); describe('aezeed seeds', () => { // The same fixture the aezeed suite uses: entropy 000102…0f under the default passphrase. That entropy // is BIP32 test vector 1's master seed, so the fingerprint below is a published constant from a third // specification — this asserts the whole chain (words → AEZ → BIP32) against something outside Officer. const AEZEED = 'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen'; const BIP32_VECTOR_1_FINGERPRINT = '3442193e'; test( 'seals as v2 and derives the BIP32 vector root', async () => { const env = await sealSeed(AEZEED, PASS); expect(env.v).toBe(2); const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT); for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy(); }, SLOW, ); test( 'does NOT derive what the BIP39 path would have', async () => { // The failure this whole `kind` mechanism exists to prevent. An aezeed phrase is 24 valid wordlist // words, so `mnemonicToSeedSync` accepts it happily and returns a perfectly real — and completely // wrong — master key, pointing at an empty wallet the owner cannot tell apart from a bad rescan. const { mnemonicToSeedSync } = await import('@scure/bip39'); const { HDKey } = await import('@scure/bip32'); const wrong = HDKey.fromMasterSeed(mnemonicToSeedSync(AEZEED)); const wrongFingerprint = Buffer.from(new Uint8Array(new Uint32Array([wrong.fingerprint]).buffer)) .reverse() .toString('hex'); expect(wrongFingerprint).not.toBe(BIP32_VECTOR_1_FINGERPRINT); const env = await sealSeed(AEZEED, PASS); const { fingerprint } = await deriveAccountXpubs(env, PASS, 'bitcoin'); expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT); }, SLOW, ); test( 'the words survive an export round trip', async () => { const env = await sealSeed(AEZEED, PASS); expect(await exportMnemonic(wid(), env, PASS)).toBe(AEZEED); }, SLOW, ); test( 'unlocking derives the same root as the stored xpub', async () => { const env = await sealSeed(AEZEED, PASS); const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin'); const s = new UnlockSession(2); await s.unlock(env, PASS, 60); expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]); s.lock(); }, SLOW, ); test( 'a wrong seed passphrase is refused at seal time, not silently accepted', async () => { // AEZ's 4-byte tag makes this loud. Without the check the wallet would seal fine and only reveal // itself later as an empty balance — the single most expensive way to find out. await expect(sealSeed(AEZEED, PASS, 'not the seed passphrase')).rejects.toThrow(/passphrase/i); }, SLOW, ); test( 'a phrase that is neither BIP39 nor aezeed is still rejected', async () => { const bad = AEZEED.split(' '); [bad[3], bad[4]] = [bad[4]!, bad[3]!]; await expect(sealSeed(bad.join(' '), PASS)).rejects.toThrow(/BIP39/i); }, SLOW, ); test( 'rotation preserves the aezeed kind and its derivation', async () => { const env = await sealSeed(AEZEED, PASS); const next = 'an entirely different passphrase'; const rotated = await changePassphrase(wid(), env, PASS, next); expect(rotated.v).toBe(2); const after = await deriveAccountXpubs(rotated, next, 'bitcoin'); expect(after.fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT); }, SLOW, ); }); describe('generateSeed', () => { test( 'produces a distinct, valid mnemonic that survives a round trip', async () => { const mnemonic = generateSeed(); expect([12, 24]).toContain(mnemonic.split(' ').length); expect(generateSeed()).not.toBe(mnemonic); const env = await sealSeed(mnemonic, 'a sufficiently long passphrase'); expect(await exportMnemonic(wid(), env, 'a sufficiently long passphrase')).toBe(mnemonic); }, SLOW, ); });