// The seed a phrase stretches to, and the vault under the blank password: // both now come from caches, and must come out exactly as before. // seed.js: WebCrypto's PBKDF2 (warmSeed) gives @scure/bip39's seed, byte // for byte, for plain, passphrased and non-ASCII (NFKD) input; seedSync // finds it waiting, and works cold. nostr.js's NIP-06 key matches // nostr-tools' privateKeyFromSeedWords. // wallet.js: a blank vault's key is kept and reused (no scrypt on the // second open); a vault sealed under a real password keeps nothing. // Run: bun tools/seed-cache-test.js import assert from 'node:assert/strict'; import { mnemonicToSeedSync, generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english.js'; import * as nip06 from 'nostr-tools/nip06'; const store = new Map(); globalThis.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) }; const { seedSync, warmSeed, forgetSeeds } = await import('../src/seed.js'); const { nostrKeyFromSeed } = await import('../src/nostr.js'); const { encryptVault, decryptVault } = await import('../src/wallet.js'); const hex = (u) => Buffer.from(u).toString('hex'); const cases = [ [generateMnemonic(wordlist), ''], [generateMnemonic(wordlist, 256), 'correct horse'], [generateMnemonic(wordlist), 'pässwörd Ⅸ'], // NFKD changes these ]; for (const [m, p] of cases) { forgetSeeds(); await warmSeed(m, p); const t = performance.now(); const warm = seedSync(m, p); assert.ok(performance.now() - t < 5, 'a warmed seed is read, not stretched'); assert.equal(hex(warm), hex(mnemonicToSeedSync(m, p)), 'WebCrypto PBKDF2 = @scure/bip39'); forgetSeeds(); assert.equal(hex(seedSync(m, p)), hex(mnemonicToSeedSync(m, p)), 'cold seedSync = @scure/bip39'); for (const i of [0, 3]) assert.equal(hex(nostrKeyFromSeed(m, p, i)), hex(nip06.privateKeyFromSeedWords(m, p || undefined, i)), 'NIP-06 key = nostr-tools'); } await warmSeed('not a phrase', ''); assert.throws(() => seedSync('not a phrase', ''), 'a malformed phrase is still refused'); console.log('✓ seeds match @scure/bip39 and nostr-tools, warmed or cold'); const list = [{ label: 'a', mnemonic: cases[0][0], passphrase: '' }]; const blob = encryptVault(list, ''); assert.ok(store.get('btc-wallet-vault-k0'), 'the blank vault key is kept'); let t = performance.now(); assert.deepEqual(decryptVault(blob, ''), list); assert.ok(performance.now() - t < 30, `opening a blank vault needs no scrypt (${Math.round(performance.now() - t)} ms)`); const blob2 = encryptVault(list, ''); assert.equal(blob2.salt, blob.salt, 'a new blank seal reuses the kept salt'); assert.notEqual(blob2.nonce, blob.nonce, 'never the nonce'); assert.deepEqual(decryptVault(blob2, ''), list); assert.throws(() => decryptVault(blob, 'guess'), 'a wrong password still fails'); assert.ok(store.get('btc-wallet-vault-k0'), 'and a failed guess drops nothing'); const sealed = encryptVault(list, 'hunter2'); assert.equal(store.get('btc-wallet-vault-k0'), undefined, 'sealing under a real password keeps nothing'); assert.deepEqual(decryptVault(sealed, 'hunter2'), list); assert.throws(() => decryptVault(sealed, '')); console.log('✓ blank vault key kept and reused; real passwords untouched'); console.log('\n✅ seed + vault caches behave');