From 249b1239bab190e6cc61f5673204134cf95d13db Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 15 Jun 2026 07:34:03 -0700 Subject: Gate recovery-phrase reveal behind a warning + Proceed step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Backup now shows the warning with the 12 words masked (••••••) and a Proceed/Cancel choice. The real words and passphrase are kept out of the DOM until Proceed, so the user reads the "make sure nobody is watching" warning before anything sensitive renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 42 +++++++++++++++++++++++++++++------------- src/style.css | 1 + 2 files changed, 30 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 48edf7d..6e98cac 100644 --- a/src/app.js +++ b/src/app.js @@ -29,7 +29,8 @@ const ui = { importText: '', passphrase: '', showPass: false, - reveal: false, // showing the recovery-phrase modal + reveal: false, // recovery-phrase modal open + revealShown: false, // phrase actually unmasked (after reading the warning) offlineFallback: false, // auto-entered offline because the network was unreachable unlockError: '', @@ -396,6 +397,7 @@ function lock() { ui.passphrase = ''; ui.confirm = []; ui.reveal = false; + ui.revealShown = false; render(); } @@ -420,7 +422,7 @@ function brandHeader(withLock) { { class: `badge dot ${wallet.offline ? 'off' : ''} ${wallet.live ? 'live' : ''}` }, status ), - withLock && h('button', { class: 'btn-sm', title: 'Show recovery phrase', onClick: () => { ui.reveal = true; render(); } }, 'Backup'), + withLock && h('button', { class: 'btn-sm', title: 'Show recovery phrase', onClick: () => { ui.reveal = true; ui.revealShown = false; render(); } }, 'Backup'), withLock && h('button', { class: 'btn-sm', onClick: lock }, 'Logout') ) ); @@ -429,8 +431,19 @@ function brandHeader(withLock) { // Modal to view the recovery phrase (+ passphrase) again — important for users // who skipped backup verification. function revealModal() { + const shown = ui.revealShown; const words = wallet.mnemonic.split(' '); - const close = () => { ui.reveal = false; render(); }; + const close = () => { ui.reveal = false; ui.revealShown = false; render(); }; + + // Until "Proceed", the real words are never put in the DOM — show 12 masked + // placeholders so the warning is read first. + const cells = words.map((w, i) => + h('div', { class: 'w' + (shown ? '' : ' masked') }, + h('span', { class: 'n' }, i + 1), + h('span', { class: 't' }, shown ? w : '••••••') + ) + ); + return h( 'div', { class: 'overlay', onClick: (e) => { if (e.target.classList.contains('overlay')) close(); } }, @@ -438,21 +451,24 @@ function revealModal() { 'div', { class: 'modal card col' }, h('h3', {}, 'Recovery phrase'), - h('div', { class: 'warn-box' }, '⚠ Anyone who can see these words can steal your funds. Make sure nobody is watching.'), - h('div', { class: 'words' }, - words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w))) - ), - wallet.passphrase + h('div', { class: 'warn-box' }, '⚠ Anyone who can see these words can steal your funds. Make sure nobody is watching before you reveal them.'), + h('div', { class: 'words' }, cells), + shown && wallet.passphrase ? h('div', { class: 'col gap6' }, h('span', { class: 'lab' }, 'BIP39 passphrase'), h('div', { class: 'addr-box' }, wallet.passphrase) ) : null, - h('div', { class: 'row gap6 wrap' }, - copyBtn(wallet.mnemonic, 'Copy phrase'), - wallet.passphrase ? copyBtn(wallet.passphrase, 'Copy passphrase') : null, - h('button', { class: 'btn-primary grow', onClick: close }, 'Done') - ) + shown + ? h('div', { class: 'row gap6 wrap' }, + copyBtn(wallet.mnemonic, 'Copy phrase'), + wallet.passphrase ? copyBtn(wallet.passphrase, 'Copy passphrase') : null, + h('button', { class: 'btn-primary grow', onClick: close }, 'Done') + ) + : h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: close }, 'Cancel'), + h('button', { class: 'btn-primary grow', onClick: () => { ui.revealShown = true; render(); } }, 'Proceed') + ) ) ); } diff --git a/src/style.css b/src/style.css index 4e0a0e2..69c26b2 100644 --- a/src/style.css +++ b/src/style.css @@ -211,6 +211,7 @@ textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-si background: rgba(20, 23, 26, 0.5); display: grid; place-items: center; } .modal { max-width: 480px; width: 100%; max-height: 90vh; overflow: auto; } +.words .w.masked .t { color: var(--faint); letter-spacing: 2px; } .divider { height: 1px; background: var(--line); margin: 16px 0; border: 0; } hr.divider { margin: 16px 0; } .linklike { background: none; border: 0; padding: 0; color: var(--accent-ink); font-weight: 600; cursor: pointer; } -- cgit v1.2.3