aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
5 files changed, 124 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) };