feat: password-gated recovery phrase display for HD wallets (closes #161)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
This commit was merged in pull request #215.
This commit is contained in:
154
src/popup/views/showPhrase.js
Normal file
154
src/popup/views/showPhrase.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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 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