diff options
| author | Adam Soltys <[email protected]> | 2026-08-17 06:45:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-17 06:45:56 -0700 |
| commit | be93ce6e37a6304d295b5ecc2f95db52964b0074 (patch) | |
| tree | 9812774de23c6a02c1e268854571a6f0b04c2f43 | |
| parent | 16f513da8d319a8f940c713ddc8358399cd2e434 (diff) | |
| download | coinosv3-be93ce6e37a6304d295b5ecc2f95db52964b0074.tar.gz coinosv3-be93ce6e37a6304d295b5ecc2f95db52964b0074.zip | |
A blank-password vault stops masquerading as a locked one: new wallets auto-save through it again, setting a real password re-encrypts every saved wallet instead of dropping the absent ones, and the save-first lock prompt finally names the password it wants
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
| -rw-r--r-- | src/app.js | 25 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | tools/padlock-flow-test.js | 96 |
3 files changed, 117 insertions, 6 deletions
@@ -1329,14 +1329,24 @@ function mergeVaultList(list) { // Keeping a wallet is the default: a seed that only lives in this tab is one // refresh away from being gone, and that surprise costs people money. So a -// wallet saves itself to the device unless we can't write the vault — when -// one exists but is locked, the manual Save button still covers it. No -// password is set here; Settings can add one later. +// wallet saves itself to the device unless the vault is locked behind a real +// password — then the manual Save button (and the padlock's save-first ask) +// still covers it. No password is set here; Settings can add one later. function autoSave(acc) { if (!acc || acc.type !== 'full' || acc.persisted || acc.provisional) return; if (vaultPassword == null) { - if (hasVault()) return; // locked vault: needs their password, leave it manual - vaultPassword = ''; + if (hasVault()) { + // A blank-password vault opens by itself — treating it as locked left + // every wallet created after a plain reload silently unsaved. Fold its + // wallets into the session first: writeVault rebuilds the blob from + // session accounts, and the saved ones must not fall out of it here. + let list; + try { list = decryptVault(loadVaultBlob(), ''); } catch { return; } // a real password: manual + vaultPassword = ''; + mergeVaultList(list); + } else { + vaultPassword = ''; + } } acc.persisted = true; writeVault(); @@ -1430,6 +1440,11 @@ function submitPw() { if (p.purpose === 'lock') { if (!p.v1) { p.error = t('pwNeededToLock'); render(); return; } if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; } + // Fold any blank-password vault contents into the session before the + // re-encrypt: writeVault rebuilds the blob from session accounts, and a + // wallet saved by an earlier session must not fall out of the vault just + // because a password is being set now. + try { mergeVaultList(decryptVault(loadVaultBlob(), '')); } catch {} vaultPassword = p.v1; writeVault(); persistAccounts(); diff --git a/src/i18n.js b/src/i18n.js index df2e62a..8e8dcd2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -80,7 +80,7 @@ const STR = { profUsername: 'Username', profDisplayName: 'Display name', profUnameInvalid: 'Usernames can only use letters, numbers, dots, dashes and underscores.', - lockSaveDesc: 'Enter your password to lock your wallet.', + lockSaveDesc: 'The wallets already saved on this device are protected by a password. Enter it to save this wallet with them and lock.', lockAction: 'Lock', password: 'Password', confirmPassword: 'Confirm password', diff --git a/tools/padlock-flow-test.js b/tools/padlock-flow-test.js index be4cdf9..ce37f1a 100644 --- a/tools/padlock-flow-test.js +++ b/tools/padlock-flow-test.js @@ -86,6 +86,102 @@ try { await sleep(600); txt = await body(); check('unlock needs the password', /password/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | ')); + + // ---- a second wallet meets the vault (fresh device) ---------------------- + const ctx2 = await browser.createBrowserContext(); + const p2 = await ctx2.newPage(); + await p2.setViewport({ width: 420, height: 880 }); + const click2 = (t) => p2.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, t); + const clickTitle2 = (t) => p2.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.title === x); if (b) { b.click(); return true; } return false; }, t); + const body2 = () => p2.evaluate(() => document.body.innerText); + const waitText2 = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await body2()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; + const importWallet = async () => { + await click2('Import existing'); + await sleep(400); + await p2.waitForSelector('textarea'); + await p2.type('textarea', generateMnemonic(wordlist)); + await click2('Open wallet'); + return waitText2('receive'); + }; + const addWallet = async () => { + await click2('Wallets'); await sleep(600); + await click2('Add wallet'); await sleep(400); + await click2('Savings'); await sleep(200); + await click2('Continue'); await sleep(600); + return importWallet(); + }; + const savingsRows = () => p2.evaluate(() => [...document.querySelectorAll('button')].map((e) => e.textContent.trim()).filter((x) => /^[●○] Savings/.test(x)).length); + + console.log('\n[a wallet created after a reload still auto-saves]'); + await p2.goto('http://localhost:5236/', { waitUntil: 'domcontentloaded' }); + await p2.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await p2.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click2('Get started'); + await sleep(500); + check('wallet A open', await importWallet()); + await sleep(2000); + await p2.reload({ waitUntil: 'domcontentloaded' }); // session restore: vault untouched this session + check('back after reload', await waitText2('receive')); + await sleep(1000); + check('wallet B open', await addWallet()); + await sleep(2000); + await clickTitle2('Lock wallet'); + await sleep(600); + txt = await body2(); + check('padlock offers to SET a password (B is saved, vault is passwordless)', + /protect this device/i.test(txt) && !/already saved on this device/i.test(txt), txt.slice(0, 140).replace(/\n+/g, ' | ')); + let f2 = await p2.$$('input[type=password]'); + await f2[0].type('hunter22'); + await f2[1].type('hunter22'); + await click2('Save'); + await sleep(1200); + check('locked in place', await waitText2('receive')); + + console.log('\n[setting the password kept every saved wallet]'); + await p2.reload({ waitUntil: 'domcontentloaded' }); + check('reload keeps the locked view on screen', await waitText2('receive')); + await clickTitle2('Unlock'); + await sleep(600); + check('re-entry asks for the password', /unlock saved wallets|enter your password/i.test(await body2())); + await (await p2.$('input[type=password]')).type('hunter22'); + await click2('Unlock'); + check('password opens it', await waitText2('receive')); + await sleep(1000); + await click2('Wallets'); await sleep(600); + check('both wallets survived the re-encrypt', (await savingsRows()) === 2, `${await savingsRows()} row(s)`); + await click2('Back'); await sleep(500); + + console.log('\n[a new wallet against a passworded vault: the ask explains itself]'); + await p2.reload({ waitUntil: 'domcontentloaded' }); // session restore again: vault password not in memory + check('back after reload', await waitText2('receive')); + await sleep(1000); + check('wallet C open', await addWallet()); + await sleep(2000); + await clickTitle2('Lock wallet'); + await sleep(600); + txt = await body2(); + check('it says WHICH password it wants', /already saved on this device/i.test(txt), txt.slice(0, 160).replace(/\n+/g, ' | ')); + await (await p2.$('input[type=password]')).type('wrong-guess'); + await click2('Lock'); + await sleep(600); + check('a wrong password is refused', /wrong password/i.test(await body2())); + await p2.evaluate(() => { const i = document.querySelector('input[type=password]'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await (await p2.$('input[type=password]')).type('hunter22'); + await click2('Lock'); + await sleep(1500); + check('the right one saves C and locks in place', await waitText2('receive')); + await p2.reload({ waitUntil: 'domcontentloaded' }); + check('locked view survives the reload', await waitText2('receive')); + await clickTitle2('Unlock'); + await sleep(600); + await (await p2.$('input[type=password]')).type('hunter22'); + await click2('Unlock'); + check('unlocked once more', await waitText2('receive')); + await sleep(1000); + await click2('Wallets'); await sleep(600); + check('all three wallets are in the vault', (await savingsRows()) === 3, `${await savingsRows()} row(s)`); + await ctx2.close(); } catch (e) { check('run completed', false, e.message); } finally { |
