diff options
| -rw-r--r-- | src/app.js | 20 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/wallet.js | 14 | ||||
| -rw-r--r-- | tools/lock-wallet-test.js | 4 |
4 files changed, 35 insertions, 8 deletions
@@ -770,12 +770,28 @@ function createPane() { 'div', { class: 'col' }, h('p', { class: 'muted' }, t('genIntro')), + // BYO randomness for the seed — folded away so the common path stays + // one button. Whatever is typed here is hashed together with the + // device's own randomness (see newMnemonic), never used alone. + h('button', { + class: 'linklike small', style: 'align-self:flex-start', + onClick: () => { ui.ownEntropyOpen = !ui.ownEntropyOpen; render(); }, + }, (ui.ownEntropyOpen ? '▾ ' : '▸ ') + t('entropyToggle')), + ui.ownEntropyOpen + ? h('div', { class: 'col gap6' }, + h('p', { class: 'small muted', style: 'margin:0' }, t('entropyHint')), + h('textarea', { + rows: '2', style: 'font-family:var(--sans)', placeholder: t('entropyPlaceholder'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + value: ui.ownEntropy || '', onInput: (e) => (ui.ownEntropy = e.target.value), + })) + : null, h( 'button', { class: 'btn-primary btn-block', onClick: () => { - ui.draftMnemonic = newMnemonic(); + ui.draftMnemonic = newMnemonic(128, (ui.ownEntropy || '').trim()); render(); }, }, @@ -804,7 +820,7 @@ function createPane() { { class: 'btn-ghost btn-sm', onClick: () => { - ui.draftMnemonic = newMnemonic(); + ui.draftMnemonic = newMnemonic(128, (ui.ownEntropy || '').trim()); render(); }, }, diff --git a/src/i18n.js b/src/i18n.js index d9dcdfb..0cd58ec 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -39,7 +39,10 @@ const STR = { accounts: 'Wallets', addWallet: 'Add wallet', clearAll: 'Delete all', - logoutForget: 'Log out & forget this device', + logoutForget: 'Logout & forget all data', + entropyToggle: 'Use my own entropy', + entropyHint: 'Type anything random — dice rolls, coin flips, shuffled cards. It gets mixed with this device’s own randomness, so it can only make the seed stronger.', + entropyPlaceholder: '4 2 6 6 1 3 5 2 …', logoutPopBlurb: 'Your wallets stay saved on this device, one unlock away.', clearAllWarn: '⚠ This removes every wallet from this device. Any wallet whose recovery phrase or key you have not backed up will be lost for good.', removeWalletTitle: 'Remove wallet', diff --git a/src/wallet.js b/src/wallet.js index 662ef0f..2ede69d 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -9,7 +9,7 @@ // transactions, only the (txid, vout, value, address) of each UTXO. import { HDKey } from '@scure/bip32'; -import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; +import { generateMnemonic, mnemonicToSeedSync, validateMnemonic, entropyToMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { hex, base64urlnopad, base32nopad, base58check } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; @@ -64,8 +64,16 @@ export const NETS = { regtest: { net: { ...btc.TEST_NETWORK, bech32: 'bcrt' }, coin: 1 }, }; -export function newMnemonic(strengthBits = 128) { - return generateMnemonic(wordlist, strengthBits); +export function newMnemonic(strengthBits = 128, extraEntropy = '') { + if (!extraEntropy) return generateMnemonic(wordlist, strengthBits); + // User entropy is MIXED with the CSPRNG, never used alone: hashing the two + // together can only add randomness — weak dice can't weaken the seed, and + // a distrusted device RNG can't fully determine it either. + const txt = new TextEncoder().encode(extraEntropy); + const rnd = crypto.getRandomValues(new Uint8Array(strengthBits / 8)); + const mix = new Uint8Array(txt.length + rnd.length); + mix.set(txt); mix.set(rnd, txt.length); + return entropyToMnemonic(sha256(mix).slice(0, strengthBits / 8), wordlist); } // The base localStorage cache key for a wallet identity (xpub, xprv, or diff --git a/tools/lock-wallet-test.js b/tools/lock-wallet-test.js index baf4710..8db392a 100644 --- a/tools/lock-wallet-test.js +++ b/tools/lock-wallet-test.js @@ -128,8 +128,8 @@ try { await click('button', 'Log out'); // the popup carries both exits await sleep(500); txt = await body(); - check('the popup offers the drastic exit too', /forget this device/i.test(txt), txt.slice(0, 160).replace(/\n+/g, ' | ')); - await click('button', 'forget this device'); + check('the popup offers the drastic exit too', /forget all data/i.test(txt), txt.slice(0, 160).replace(/\n+/g, ' | ')); + await click('button', 'forget all data'); await sleep(600); txt = await body(); check('it routes through the Delete-all warning', /delete all/i.test(txt) && /lost for good/i.test(txt), txt.slice(0, 160).replace(/\n+/g, ' | ')); |
