aboutsummaryrefslogtreecommitdiffstats
path: root/tools/seed-cache-test.js
blob: 6250d65501915bd7b5fa914680db363f9cde6f56 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 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');