Leaving the private key export or recovery phrase screen drops the selection it was showing, but the settings gear had just pushed the screen onto the Back stack, so Back from Settings landed on a password prompt that could only fail. Each screen's leave handler now also takes it off the top of the stack, which is what a reopened popup already does to these screens. Back from Settings goes to the address screen for the export screen; for the recovery phrase screen, opened from Settings, it stays on Settings once, as after a reopen. Jest tests drive the gear and then Back, and each screen's own Back, for both screens; leavePrivkeyScreen() in the e2e suite expects the address screen. Model: opus-5-5
164 lines
5.9 KiB
JavaScript
164 lines
5.9 KiB
JavaScript
// 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,
|
|
// and a decrypt still in flight when that happens is discarded
|
|
// instead of written (revealGeneration).
|
|
// 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,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const { state } = require("../../shared/state");
|
|
const { decryptWithPassword } = require("../../shared/vault");
|
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
|
|
|
const VIEW = "show-phrase";
|
|
|
|
let walletIndex = null;
|
|
|
|
// Bumped by every clear(), which is what leaving the screen runs. reveal()
|
|
// captures it before awaiting the decrypt and refuses to touch the DOM if
|
|
// it has moved: a decrypt still in flight when the screen is left would
|
|
// otherwise write the phrase *after* the wipe, with nothing scheduled to
|
|
// wipe it again, leaving it in the hidden view for the life of the popup.
|
|
let revealGeneration = 0;
|
|
|
|
// True only if the reveal that captured `generation` is still the live one:
|
|
// the screen has not been left, cleared, or re-entered for another wallet
|
|
// since it started.
|
|
function isCurrentReveal(generation) {
|
|
return (
|
|
generation === revealGeneration &&
|
|
walletIndex !== null &&
|
|
state.currentView === VIEW
|
|
);
|
|
}
|
|
|
|
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;
|
|
revealGeneration += 1;
|
|
$("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);
|
|
// Pushed here rather than by the caller: this function can return
|
|
// without navigating, and a push that happened anyway would leave an
|
|
// entry on the stack that no screen transition matches.
|
|
pushCurrentView();
|
|
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");
|
|
const generation = revealGeneration;
|
|
try {
|
|
const phrase = await decryptWithPassword(
|
|
wallet.encryptedSecret,
|
|
password,
|
|
);
|
|
// The only suspension point in this view, and the only place a
|
|
// secret is written: if the screen was left while the decrypt ran,
|
|
// the wipe has already happened and this write must not land.
|
|
if (!isCurrentReveal(generation)) return;
|
|
$("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 {
|
|
if (!isCurrentReveal(generation)) return;
|
|
// 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 incorrect. Please try again.");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.classList.remove("text-muted");
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
// Leaving drops the wallet selection, so the screen also comes off the
|
|
// Back stack, where the settings gear has just put it: Back from Settings
|
|
// must not land on a password prompt that can only fail. A reopened popup
|
|
// drops it from the stack the same way
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
|
onViewLeave(VIEW, () => {
|
|
clear();
|
|
const stack = state.viewStack;
|
|
if (stack[stack.length - 1] === VIEW) stack.pop();
|
|
});
|
|
|
|
$("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 };
|