diff options
| author | Adam Soltys <[email protected]> | 2026-11-06 22:55:11 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-06 22:55:11 -0700 |
| commit | 1fe10057f301b09bbad0cfccdfecac9826733048 (patch) | |
| tree | 183ec6f7eabd5de3b4dd1af53e692f6be8264563 /src/wallet.js | |
| parent | c44377f0f103c2f17805d9e0bf4d2688e962af5e (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src/wallet.js')
| -rw-r--r-- | src/wallet.js | 38 |
1 files changed, 33 insertions, 5 deletions
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) }; |
