Some checks failed
check / check (push) Has been cancelled
A rejected password was reported three different ways depending on which screen you were on, including the fragment "Wrong password." which is not a sentence. All six decryptWithPassword call sites now show the same full sentence. Strings only -- a wrong password still fails closed on every screen and still resolves no pending approval. A test pins the invariant per call site: each decryptWithPassword call is walked out to its enclosing try and forward to that block's catch, and the prose shown there must equal the canonical sentence. Per-file matching was not enough, since a file with two call sites kept passing while one of them diverged.
155 lines
5.4 KiB
JavaScript
155 lines
5.4 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() {
|
|
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 };
|