aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-17 06:45:56 -0700
committerAdam Soltys <[email protected]>2026-08-17 06:45:56 -0700
commitbe93ce6e37a6304d295b5ecc2f95db52964b0074 (patch)
tree9812774de23c6a02c1e268854571a6f0b04c2f43
parent16f513da8d319a8f940c713ddc8358399cd2e434 (diff)
downloadcoinosv3-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.js25
-rw-r--r--src/i18n.js2
-rw-r--r--tools/padlock-flow-test.js96
3 files changed, 117 insertions, 6 deletions
diff --git a/src/app.js b/src/app.js
index 7e7dc9a..de3e890 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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 {