feat: password-gated recovery phrase display for HD wallets (closes #161)
All checks were successful
check / check (push) Successful in 32s
All checks were successful
check / check (push) Successful in 32s
A user who created a wallet in AutistMask and did not write the phrase
down had no way to retrieve it. Adds a "Show recovery phrase" action on
the wallet row in Settings, next to the per-wallet actions that already
live there, mirroring the per-address private key export in structure,
password gate and warning treatment.
The screen displays the secret that owns every address in the wallet, so:
- Only HD wallets are offered it. walletHasRecoveryPhrase() is an
allowlist on type "hd", so the key and xprv types — which have no
phrase at all — are excluded, as is any type added later.
- Nothing is decrypted and nothing enters the page until
decryptWithPassword accepts the password. A wrong password produces a
full-sentence error and leaves the value node empty.
- Leaving the screen wipes it by any route, not just "Back": views that
hold a secret register a cleanup with showView() via onViewLeave(),
which also covers the settings gear.
- The phrase is never assigned to state, so it cannot be persisted, and
the view is not in RESTORABLE_VIEWS — reopening the popup lands on
Home. That set moves to src/popup/restorableViews.js so the exclusion
can be asserted directly; the popup entry point cannot be required
outside a browser.
- The phrase cannot reach the logger: the view does not import
src/shared/log.js, and the failed-decrypt path reports a fixed
sentence rather than the caught error.
Tests: unit coverage for the type gate, the RESTORABLE_VIEWS exclusion
and the absence of any logger path; the DOM behaviour is driven against
the real popup in the e2e suite, which is where this repo tests views.
This commit is contained in:
@@ -31,8 +31,20 @@ const VIEWS = [
|
||||
"approve-tx",
|
||||
"approve-sign",
|
||||
"export-privkey",
|
||||
"show-phrase",
|
||||
];
|
||||
|
||||
// Cleanup callbacks for views that hold a secret in the DOM. The view
|
||||
// registers one for itself and showView() runs it whenever that view is
|
||||
// navigated away from, so the secret is wiped no matter which control
|
||||
// caused the navigation — "Back", the settings gear, or a jump from
|
||||
// anywhere else. A per-button clear would only cover the one path.
|
||||
const viewLeaveHandlers = new Map();
|
||||
|
||||
function onViewLeave(name, fn) {
|
||||
viewLeaveHandlers.set(name, fn);
|
||||
}
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
@@ -50,6 +62,11 @@ function hideError(id) {
|
||||
}
|
||||
|
||||
function showView(name) {
|
||||
const leaving = state.currentView;
|
||||
if (leaving && leaving !== name) {
|
||||
const onLeave = viewLeaveHandlers.get(leaving);
|
||||
if (onLeave) onLeave();
|
||||
}
|
||||
for (const v of VIEWS) {
|
||||
const el = document.getElementById(`view-${v}`);
|
||||
if (el) {
|
||||
@@ -431,10 +448,12 @@ function flashCopyFeedback(el) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VIEWS,
|
||||
$,
|
||||
showError,
|
||||
hideError,
|
||||
showView,
|
||||
onViewLeave,
|
||||
updateDebugBanner,
|
||||
setRenderMain,
|
||||
pushCurrentView,
|
||||
|
||||
@@ -14,6 +14,8 @@ const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||
const deleteWallet = require("./deleteWallet");
|
||||
const showPhrase = require("./showPhrase");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
const {
|
||||
BUILD_VERSION,
|
||||
BUILD_LICENSE,
|
||||
@@ -99,7 +101,14 @@ function renderWalletListSettings() {
|
||||
const name = escapeHtml(wallet.name || "Wallet " + (idx + 1));
|
||||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||||
html += `<span class="settings-wallet-name cursor-pointer underline decoration-dashed" data-idx="${idx}">${name}</span>`;
|
||||
html += `<span class="flex items-center gap-1 flex-shrink-0">`;
|
||||
// Key and xprv wallets have no recovery phrase, so they are never
|
||||
// offered the action at all.
|
||||
if (walletHasRecoveryPhrase(wallet)) {
|
||||
html += `<button class="btn-show-phrase border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}" title="Show recovery phrase">[recovery phrase]</button>`;
|
||||
}
|
||||
html += `<button class="btn-delete-wallet border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;
|
||||
html += `</span>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
@@ -111,6 +120,14 @@ function renderWalletListSettings() {
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll(".btn-show-phrase").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
pushCurrentView();
|
||||
showPhrase.show(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Inline rename on click
|
||||
container.querySelectorAll(".settings-wallet-name").forEach((span) => {
|
||||
span.addEventListener("click", () => {
|
||||
@@ -191,6 +208,7 @@ function renderSiteLists() {
|
||||
|
||||
function init(ctx) {
|
||||
deleteWallet.init(ctx);
|
||||
showPhrase.init();
|
||||
|
||||
$("btn-save-rpc").addEventListener("click", async () => {
|
||||
const url = $("settings-rpc").value.trim();
|
||||
|
||||
122
src/popup/views/showPhrase.js
Normal file
122
src/popup/views/showPhrase.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Recovery phrase display for HD wallets.
|
||||
//
|
||||
// The phrase is the secret that owns every address in the wallet, so it is
|
||||
// handled under four rules:
|
||||
//
|
||||
// 1. Only an HD wallet reaches this screen (walletHasRecoveryPhrase).
|
||||
// 2. Nothing is decrypted, and nothing is written into the DOM, until
|
||||
// decryptWithPassword has accepted the password.
|
||||
// 3. Leaving the screen by any path wipes it, via the onViewLeave hook.
|
||||
// 4. The phrase never reaches the logger. This module deliberately does
|
||||
// not import src/shared/log.js, and the failed-decrypt path reports a
|
||||
// fixed sentence rather than the caught error.
|
||||
//
|
||||
// The phrase is also never assigned to `state`, so it cannot be persisted
|
||||
// to extension storage, and "show-phrase" is excluded from RESTORABLE_VIEWS
|
||||
// so the popup can never reopen onto it.
|
||||
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
|
||||
const VIEW = "show-phrase";
|
||||
|
||||
let walletIndex = null;
|
||||
|
||||
function fail(message) {
|
||||
$("show-phrase-flash").textContent = message;
|
||||
$("show-phrase-flash").style.visibility = "visible";
|
||||
}
|
||||
|
||||
// Wipe every trace of the phrase and drop the wallet selection. Safe to
|
||||
// call when nothing was ever revealed, and safe to call twice.
|
||||
function clear() {
|
||||
walletIndex = null;
|
||||
$("show-phrase-value").textContent = "";
|
||||
$("show-phrase-password").value = "";
|
||||
$("show-phrase-result").classList.add("hidden");
|
||||
$("show-phrase-password-section").classList.remove("hidden");
|
||||
$("show-phrase-flash").textContent = "";
|
||||
$("show-phrase-flash").style.visibility = "hidden";
|
||||
}
|
||||
|
||||
function show(walletIdx) {
|
||||
const wallet = state.wallets[walletIdx];
|
||||
if (!walletHasRecoveryPhrase(wallet)) {
|
||||
showFlash("This wallet does not have a recovery phrase.");
|
||||
return;
|
||||
}
|
||||
clear();
|
||||
walletIndex = walletIdx;
|
||||
$("show-phrase-wallet-name").textContent =
|
||||
wallet.name || "Wallet " + (walletIdx + 1);
|
||||
showView(VIEW);
|
||||
}
|
||||
|
||||
async function reveal() {
|
||||
const password = $("show-phrase-password").value;
|
||||
if (!password) {
|
||||
fail("Please enter your password.");
|
||||
return;
|
||||
}
|
||||
if (walletIndex === null) {
|
||||
fail("No wallet is selected.");
|
||||
return;
|
||||
}
|
||||
const wallet = state.wallets[walletIndex];
|
||||
if (!walletHasRecoveryPhrase(wallet)) {
|
||||
fail("This wallet does not have a recovery phrase.");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $("btn-show-phrase-reveal");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
try {
|
||||
const phrase = await decryptWithPassword(
|
||||
wallet.encryptedSecret,
|
||||
password,
|
||||
);
|
||||
$("show-phrase-password").value = "";
|
||||
$("show-phrase-password-section").classList.add("hidden");
|
||||
$("show-phrase-value").textContent = phrase;
|
||||
$("show-phrase-result").classList.remove("hidden");
|
||||
$("show-phrase-flash").textContent = "";
|
||||
$("show-phrase-flash").style.visibility = "hidden";
|
||||
} catch {
|
||||
// Deliberately not the caught error: the message is fixed so that
|
||||
// nothing derived from the ciphertext or the attempt can surface.
|
||||
fail("That password is not correct. Please try again.");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
onViewLeave(VIEW, clear);
|
||||
|
||||
$("btn-show-phrase-back").addEventListener("click", () => {
|
||||
goBack();
|
||||
});
|
||||
|
||||
$("btn-show-phrase-reveal").addEventListener("click", reveal);
|
||||
|
||||
$("show-phrase-value").addEventListener("click", () => {
|
||||
const phrase = $("show-phrase-value").textContent;
|
||||
if (!phrase) return;
|
||||
navigator.clipboard.writeText(phrase);
|
||||
showFlash("Copied!");
|
||||
flashCopyFeedback($("show-phrase-value"));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
Reference in New Issue
Block a user