diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 23 | ||||
| -rw-r--r-- | src/features/zaps.js | 4 | ||||
| -rw-r--r-- | src/nostr.js | 19 | ||||
| -rw-r--r-- | src/seed.js | 53 | ||||
| -rw-r--r-- | src/wallet.js | 38 |
5 files changed, 124 insertions, 13 deletions
@@ -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) }; |
