diff options
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) }; |
