aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-06 22:55:11 -0700
committerAdam Soltys <[email protected]>2026-11-06 22:55:11 -0700
commit1fe10057f301b09bbad0cfccdfecac9826733048 (patch)
tree183ec6f7eabd5de3b4dd1af53e692f6be8264563
parentc44377f0f103c2f17805d9e0bf4d2688e962af5e (diff)
downloadcoinosv3-1fe10057f301b09bbad0cfccdfecac9826733048.tar.gz
coinosv3-1fe10057f301b09bbad0cfccdfecac9826733048.zip
Opening the wallet no longer stretches the same keys in JavaScript two and three times over. Profiled on Adam's Pixel 9 at 4x CPU (a mid-range phone), a cold launch — a fresh tab, so the wallet comes out of the device vault — spent ~850 ms of its 1.9 s to the balance stretching keys on the main thread: scrypt (N=2^15) to open the vault under its blank password (~520 ms), then PBKDF2-2048 of the same twelve words once per saved wallet to match it to its watch-only entry and twice more for the open one (the BIP84 account and, inside nostr-tools' nip06, the nostr identity). Now src/seed.js keeps each phrase's BIP-39 seed: the boot stretches the phrases it's about to open with WebCrypto's native PBKDF2 while the stores open (warmBootSeeds, alongside the IndexedDB wait it already had), and the wallet, zaps and the nostr key (NIP-06 m/44'/1237'/n'/0/0, derived from that seed — nostrKeyFromSeed) read it; a reader that comes first stretches it the old way and leaves it for the rest. A lock forgets them. The vault under the BLANK password — the default, which scrypt protects from no one, since anyone with the blob can compute scrypt('') — keeps its key beside the blob and new blank seals reuse that salt (fresh nonce each time); sealing under a real password drops it, and real passwords are stretched every time as before. Same cold launch at 4x: balance 2.2–2.9 s → ~1.5 s, scrypt and the JS PBKDF2 gone from the profile. tools/seed-cache-test.js: WebCrypto's seed = @scure/bip39's for plain, passphrased and NFKD input, warmed or cold; the NIP-06 key = nostr-tools'; a malformed phrase still refused; a blank vault opens without scrypt and reseals with the kept salt, a wrong guess fails and drops nothing, a real password keeps nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6Ts5Tktega8VbLg31q
-rw-r--r--src/app.js23
-rw-r--r--src/features/zaps.js4
-rw-r--r--src/nostr.js19
-rw-r--r--src/seed.js53
-rw-r--r--src/wallet.js38
-rw-r--r--tools/seed-cache-test.js60
6 files changed, 184 insertions, 13 deletions
diff --git a/src/app.js b/src/app.js
index 4dd1235..2d29c4f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -11,6 +11,7 @@ import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
+import { warmSeed, forgetSeeds } from './seed.js';
import { nip98Header } from './nip98.js';
import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
@@ -2171,6 +2172,25 @@ function bootWillRestore() {
}
const restorePending = () => _restorePending && bootWillRestore();
+// The phrases the restore below is about to open, stretched into their seeds
+// natively (seed.js) while the stores open: the restore and the wallet then
+// read them instead of stretching each in JavaScript, once per use.
+async function warmBootSeeds() {
+ let list = [];
+ try {
+ if (!unlockDeadlinePassed()) {
+ const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null');
+ if (s && Array.isArray(s.accounts)) list = s.accounts;
+ }
+ } catch {}
+ if (!list.length && vaultOpensBlank()) {
+ try { list = decryptVault(loadVaultBlob(), ''); } catch {}
+ }
+ await Promise.all((Array.isArray(list) ? list : [])
+ .filter((a) => a && a.mnemonic && !a.xprv)
+ .map((a) => warmSeed(a.mnemonic, a.passphrase || '')));
+}
+
function restoreAccountsState() {
let sess = null;
try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {}
@@ -2737,6 +2757,7 @@ function lock({ offerPassword = false } = {}) {
_featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
clearAccounts();
+ forgetSeeds();
vaultPassword = null;
vaultPasskeyId = null;
wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false });
@@ -6977,7 +6998,7 @@ loadLocale(getLang()).finally(async () => {
const storeReady = featureHook('arkStoreReady');
// the IndexedDB caches (profiles, reports, …) open alongside: a wallet's
// caches are read as soon as it opens
- await Promise.all([storeReady, cacheReady()].filter(Boolean)).catch(() => {});
+ await Promise.all([storeReady, cacheReady(), warmBootSeeds()].filter(Boolean)).catch(() => {});
// Ask the browser not to evict this site's storage under disk pressure.
// Chrome answers without a prompt (yes for an installed or busy site).
try { navigator.storage?.persist?.().catch(() => {}); } catch {}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index fbdc2e9..8ac78fb 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -15,7 +15,7 @@ import { t } from '../i18n.js';
import { npubOf } from '../nostr.js';
import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js';
import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync } from '@scure/bip39';
+import { seedSync } from '../seed.js';
import { decodeNoffer } from '../noffer.js';
import { requestOfferInvoice } from '../clink.js';
import { decodeOffer } from '../bolt12.js';
@@ -322,7 +322,7 @@ export function zapsFeature(ctx) {
render();
}
const masterKey = () => {
- if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || ''));
+ if (wallet.mnemonic) return HDKey.fromMasterSeed(seedSync(wallet.mnemonic, wallet.passphrase || ''));
if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv);
return null;
};
diff --git a/src/nostr.js b/src/nostr.js
index 731c076..4b7f5c3 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -9,7 +9,8 @@
// Sync is on by default (relay.coinos.io) but can be turned off or pointed at
// other relays in Settings; the preference lives in localStorage.
-import * as nip06 from 'nostr-tools/nip06';
+import { HDKey } from '@scure/bip32';
+import { seedSync } from './seed.js';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
@@ -650,13 +651,21 @@ export { getPublicKey, finalizeEvent, generateSecretKey };
// The nostr identity a seed carries (NostrSync#load's key, without the
// wallet) — so the account switcher can put a face on every account on the
-// device, not just the one that's open. Memoized: nip06 stretches the seed.
+// device, not just the one that's open. Memoized: the seed is stretched.
+// NIP-06: m/44'/1237'/<account>'/0/0 of the BIP-39 seed — what nostr-tools'
+// privateKeyFromSeedWords derives, from the seed the wallet already stretched
+// (seed.js) instead of stretching the words a second time.
+export function nostrKeyFromSeed(mnemonic, passphrase = '', accountIndex = 0) {
+ const sk = HDKey.fromMasterSeed(seedSync(mnemonic, passphrase || '')).derive(`m/44'/1237'/${accountIndex || 0}'/0/0`).privateKey;
+ if (!sk) throw new Error('could not derive private key');
+ return sk;
+}
const _seedPks = new Map();
export function seedPubkey(mnemonic, passphrase = '', accountIndex = 0) {
const k = `${mnemonic}\n${passphrase}\n${accountIndex}`;
if (_seedPks.has(k)) return _seedPks.get(k);
let pk = null;
- try { pk = getPublicKey(nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0)); } catch {}
+ try { pk = getPublicKey(nostrKeyFromSeed(mnemonic, passphrase, accountIndex)); } catch {}
_seedPks.set(k, pk);
return pk;
}
@@ -839,8 +848,8 @@ export class NostrSync {
load(mnemonic, passphrase = '', accountIndex = 0) {
// a derived sibling wallet (same seed, higher BIP84 account) gets its own
- // nostr identity and sync slots — nip06 shifts by account index
- this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0);
+ // nostr identity and sync slots — NIP-06 shifts by account index
+ this.sk = nostrKeyFromSeed(mnemonic, passphrase, accountIndex);
this.pk = getPublicKey(this.sk);
this.ck = nip44.getConversationKey(this.sk, this.pk);
}
diff --git a/src/seed.js b/src/seed.js
new file mode 100644
index 0000000..0592035
--- /dev/null
+++ b/src/seed.js
@@ -0,0 +1,53 @@
+// The BIP-39 seed of a recovery phrase, stretched once per phrase.
+//
+// Stretching is PBKDF2-HMAC-SHA512 over 2048 rounds. Done in JavaScript
+// (@scure/bip39, nostr-tools' nip06) it cost every launch twice over — the
+// wallet's account key and the nostr identity each stretched the same words —
+// and was the largest single piece of opening a wallet on a phone. The browser
+// has the same function built in (WebCrypto), many times faster but
+// asynchronous: warmSeed() runs it before a wallet opens, and the synchronous
+// readers (seedSync) find the result waiting. A reader that comes first
+// still gets the right seed, the slow way, and leaves it for the rest.
+import { mnemonicToSeedSync } from '@scure/bip39';
+
+const seeds = new Map(); // `${phrase}\n${passphrase}` → Uint8Array(64)
+const MAX = 8; // a few accounts on one device; oldest out first
+const key = (mnemonic, passphrase) => `${mnemonic}\n${passphrase || ''}`;
+function keep(k, seed) {
+ seeds.delete(k);
+ seeds.set(k, seed);
+ while (seeds.size > MAX) seeds.delete(seeds.keys().next().value);
+}
+
+export function seedSync(mnemonic, passphrase = '') {
+ const k = key(mnemonic, passphrase);
+ const hit = seeds.get(k);
+ if (hit) return hit;
+ const seed = mnemonicToSeedSync(mnemonic, passphrase || '');
+ keep(k, seed);
+ return seed;
+}
+
+// BIP-39 exactly: password = NFKD(phrase), salt = NFKD("mnemonic" + passphrase).
+// Anything that goes wrong leaves the cache empty; seedSync does it the slow way.
+export async function warmSeed(mnemonic, passphrase = '') {
+ if (!mnemonic) return;
+ const k = key(mnemonic, passphrase);
+ if (seeds.has(k)) return;
+ // what @scure/bip39 accepts; anything else is left for it to refuse
+ if (![12, 15, 18, 21, 24].includes(mnemonic.normalize('NFKD').split(' ').length)) return;
+ try {
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) return;
+ const enc = new TextEncoder();
+ const pw = await subtle.importKey('raw', enc.encode(mnemonic.normalize('NFKD')), 'PBKDF2', false, ['deriveBits']);
+ const bits = await subtle.deriveBits(
+ { name: 'PBKDF2', hash: 'SHA-512', salt: enc.encode(('mnemonic' + (passphrase || '')).normalize('NFKD')), iterations: 2048 },
+ pw, 512,
+ );
+ if (!seeds.has(k)) keep(k, new Uint8Array(bits));
+ } catch {}
+}
+
+// A locked or removed wallet's seed doesn't stay behind in memory.
+export function forgetSeeds() { seeds.clear(); }
diff --git a/src/wallet.js b/src/wallet.js
index aca11e1..72e0c33 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -9,7 +9,8 @@
// transactions, only the (txid, vout, value, address) of each UTXO.
import { HDKey } from '@scure/bip32';
-import { generateMnemonic, mnemonicToSeedSync, validateMnemonic, entropyToMnemonic } from '@scure/bip39';
+import { generateMnemonic, validateMnemonic, entropyToMnemonic } from '@scure/bip39';
+import { seedSync } from './seed.js';
import { wordlist } from '@scure/bip39/wordlists/english';
import { hex, base64urlnopad, base32nopad, base58check } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
@@ -87,7 +88,7 @@ export function accountXpubFor({ mnemonic, passphrase = '', xprv } = {}, netName
const node = HDKey.fromExtendedKey(xprv);
acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/0'`) : node;
} else {
- acct = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)).derive(`m/84'/${coin}'/0'`);
+ acct = HDKey.fromMasterSeed(seedSync(mnemonic, passphrase)).derive(`m/84'/${coin}'/0'`);
}
return acct.publicExtendedKey;
}
@@ -278,7 +279,7 @@ export class Wallet {
const node = HDKey.fromExtendedKey(this.xprv);
acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/${this.accountIndex || 0}'`) : node;
} else {
- acct = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/${this.accountIndex || 0}'`);
+ acct = HDKey.fromMasterSeed(seedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/${this.accountIndex || 0}'`);
}
this._account = acct;
this._accountKey = key;
@@ -2013,11 +2014,38 @@ export function parseExtendedKey(s) {
// wrong password fails to decrypt rather than returning garbage). All fields
// are hex so the blob is JSON-serializable for localStorage.
const _SCRYPT = { N: 2 ** 15, r: 8, p: 1, dkLen: 32 };
+// A vault under the BLANK password (the default: wallets save themselves to
+// the device unless a password is set) gets nothing from scrypt — anyone with
+// the blob can compute scrypt('') — yet paid for it on every cold launch:
+// ~130 ms on a Pixel 9, half a second on a slower phone, before anything
+// opened. Its key is kept beside the blob instead, and new seals reuse that
+// salt so the kept key stays good. A real password is stretched every time,
+// as before, and never kept.
+const BLANK_KEY = 'btc-wallet-vault-k0';
+let _blank = null; // { salt: hex, key: Uint8Array }
+function blankKey() {
+ if (_blank) return _blank;
+ try {
+ const j = JSON.parse(globalThis.localStorage?.getItem(BLANK_KEY) || 'null');
+ if (j && typeof j.salt === 'string' && typeof j.key === 'string') _blank = { salt: j.salt, key: hex.decode(j.key) };
+ } catch {}
+ return _blank;
+}
function _vaultKey(password, salt) {
- return scrypt(utf8ToBytes(password), salt, _SCRYPT);
+ if (password !== '') return scrypt(utf8ToBytes(password), salt, _SCRYPT);
+ const k = blankKey();
+ const sh = hex.encode(salt);
+ if (k && k.salt === sh) return k.key;
+ const key = scrypt(utf8ToBytes(''), salt, _SCRYPT);
+ _blank = { salt: sh, key };
+ try { globalThis.localStorage?.setItem(BLANK_KEY, JSON.stringify({ salt: sh, key: hex.encode(key) })); } catch {}
+ return key;
}
export function encryptVault(obj, password) {
- const salt = randomBytes(16);
+ // sealed under a real password now: the blank one's key has nothing to open
+ if (password !== '') { _blank = null; try { globalThis.localStorage?.removeItem(BLANK_KEY); } catch {} }
+ const kept = password === '' ? blankKey() : null;
+ const salt = kept ? hex.decode(kept.salt) : randomBytes(16);
const nonce = randomBytes(24);
const ct = xchacha20poly1305(_vaultKey(password, salt), nonce).encrypt(utf8ToBytes(JSON.stringify(obj)));
return { v: 1, salt: hex.encode(salt), nonce: hex.encode(nonce), ct: hex.encode(ct) };
diff --git a/tools/seed-cache-test.js b/tools/seed-cache-test.js
new file mode 100644
index 0000000..6250d65
--- /dev/null
+++ b/tools/seed-cache-test.js
@@ -0,0 +1,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');