feat(aegis): 0.22.0 — show a wallet's secret key, behind the PIN

Getting a key back out of Aegis only worked for wallets it derived itself.
Every imported adapter's recovery() returned xprv:null with "Recovery lives
in the source of the import", so the wallets most likely to need exporting
were the ones that refused, and the vault-derived ones handed over an xprv
behind nothing but an approval click.

There is one gate now, and it is enforced in the host. revealSecret takes the
master password and verifies it with vault.lifecycle.unlock before it reads
anything; the panel obtains that password either by decrypting the PIN blob,
which wraps exactly it, or by asking. Both routes end at the same proof, so
the host never takes the panel's word for authorisation. The old
recovery({reveal:true}) path is gone and all six Settings buttons route here.

Three wrong PINs switch to the master password rather than dead-ending, and
those attempts still count toward the existing 15-minute lockout, so a
fumbled PIN costs nothing and a guessed one gains nothing. Being locked out
of the PIN also falls through to the password: the lockout exists to stop PIN
guessing, not to lock an owner out of their own key.

"Use PIN to show secret keys" defaults ON, unlike the send flag — a send is
already fronted by an approval overlay, whereas a revealed key is
irreversible the moment it is on screen. Turning it off moves the prompt to
the master password. There is deliberately no setting that reveals a key
without asking for anything.

It is NOT called a recovery phrase, because Aegis has none to show. An import
stores mnemonicToSeedHex(words) and discards the words, vault wallets are
HKDF(vault root, purpose) and never had words, and password-vault.js is
explicit that the seed is never persisted. So each form names itself — WIF,
private key (hex), wallet seed (hex), wallet key (hex) — and says where it
can actually be restored. Someone who writes down what this shows believing
it is twelve words has backed up nothing, which is the one outcome this
screen has to prevent.
This commit is contained in:
Local Dev 2026-10-02 00:04:06 +02:00
parent 6bb3c50d0d
commit 6c5fa9cea8
4 changed files with 332 additions and 55 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.21.0", "version": "0.22.0",
"category": "plugin", "category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -1785,19 +1785,91 @@ function registerPanelMessages(api) {
const rt = requireWallet(id); const rt = requireWallet(id);
if (typeof rt.adapter.recovery !== "function") throw new Error("this chain does not expose recovery details"); if (typeof rt.adapter.recovery !== "function") throw new Error("this chain does not expose recovery details");
const r = rt.adapter.recovery(); const r = rt.adapter.recovery();
const out = { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose }; // Public material only. Revealing a secret goes through revealSecret,
if (p && p.reveal) { // which proves the master password first — this used to hand back the
// Sia's "xprv" is really the 32-byte wallet seed (walletd KeyFromSeed); // xprv behind nothing but an approval click.
// BCH/DGB's is the account xprv. Warning copy fits both. return { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose };
const pick = await api.approvalModal({
title: rt.entry.chain === "sc" ? "Reveal the wallet seed?" : "Reveal the account private key?",
origin: "Aegis wallet panel",
body: "Anyone holding this can spend every coin in this wallet. It stays on screen until you close the Settings tab.",
actions: [{ id: "reveal", label: "Reveal", danger: true }],
}); });
if (pick === "reveal") out.xprv = r.xprv;
// ---- reveal a wallet's secret ------------------------------------------
// Authorisation is the master password, and it is verified HERE rather
// than taken on trust from the panel: the PIN route never touches
// vaultUnlock, so without this check the only thing between a secret and
// the screen would be panel-side logic. The panel gets the password either
// by decrypting the PIN blob (which wraps exactly this) or by asking.
//
// What comes back is NOT a recovery phrase, because no BIP39 mnemonic is
// stored anywhere in Aegis or the Theseus vault. An import keeps
// mnemonicToSeedHex(words) and discards the words (PBKDF2, one-way), and a
// vault wallet is HKDF(vault root, purpose) and never had words of its
// own — password-vault.js is explicit that the seed is never persisted.
// Calling any of this a "recovery phrase" in the UI would be a lie that
// costs someone their backup, so every form names itself and says where it
// can actually be restored.
api.onMessage("revealSecret", async (p, m) => {
fromPanel(m);
const pw = String((p && p.masterPassword) || "");
if (!pw) throw new Error("master password required");
try { await api.vault.lifecycle.unlock(pw); }
catch (e) {
// A missing or unset-up vault is a structural failure, not a bad
// password — don't accuse the user of mistyping something that was
// never going to work.
const msg = e?.message || String(e);
if (/no vault|not set up/i.test(msg)) throw new Error(msg);
throw new Error("wrong master password");
} }
return out;
const id = String((p && p.walletId) || selectedWalletId() || "");
const entry = walletEntries().find((w) => w.id === id);
if (!entry) throw new Error("no such wallet");
const meta = chainMeta(entry.chain, entry.network);
const base = {
walletId: entry.id, label: entry.label, chain: entry.chain,
coinLabel: meta?.coinLabel || entry.chain,
networkLabel: meta?.networkLabel || entry.network,
address: ctx.runtimes.get(entry.id)?.adapter?.snapshot()?.address || null,
};
if (entry.kind === "imported") {
if (!api.vault?.imports || typeof api.vault.imports.signer !== "function") {
throw new Error("this build cannot read imported keys");
}
const blob = await api.vault.imports.signer(entry.importId);
if (blob.kind === "wif") {
// ETH/TRX/SOL raw hex keys ride in the wif slot behind a scheme
// prefix (see importWallet) — unwrap so the user sees the key.
const w = String(blob.wif || "");
const PRIVHEX = "aegis-privhex:";
return w.startsWith(PRIVHEX)
? { ...base, form: "privhex", secret: w.slice(PRIVHEX.length) }
: { ...base, form: "wif", secret: w };
}
if (blob.kind === "seed") {
return { ...base, form: "seed", secret: String(blob.seed || ""), path: blob.path || null };
}
throw new Error("unknown import kind: " + blob.kind);
}
// Vault-derived. Two genuinely useful forms: the account xprv, which
// other HD wallets accept, and the 32-byte purpose root Aegis derives
// from. Sia's recovery() calls its seed "xprv" and it is the same bytes
// as the root, so don't report it twice.
let xprv = null, xpub = null, accountPath = null;
const rt = ctx.runtimes.get(entry.id);
if (rt && rt.adapter && typeof rt.adapter.recovery === "function") {
try {
const r = rt.adapter.recovery();
xpub = r.xpub || null;
accountPath = r.accountPath || null;
if (entry.chain !== "sc") xprv = r.xprv || null;
} catch { /* public material is a bonus, not the point */ }
}
const root = await api.vault.derive(entry.purpose);
let rootHex = "";
try { rootHex = Array.from(root, (b) => b.toString(16).padStart(2, "0")).join(""); }
finally { try { root.fill(0); } catch {} }
return { ...base, form: "vault", secret: rootHex, purpose: entry.purpose, xprv, xpub, accountPath };
}); });
// Opt-in USD prices. Persist the choice so restart doesn't silently // Opt-in USD prices. Persist the choice so restart doesn't silently
@ -2052,6 +2124,12 @@ function registerPanelMessages(api) {
return { return {
hasPin: !!api.storage.get("aegis/pin/v1", null), hasPin: !!api.storage.get("aegis/pin/v1", null),
requirePinForSending: !!cfg.requirePinForSending, requirePinForSending: !!cfg.requirePinForSending,
// Defaults ON (note the !== false), unlike the send flag: a send is
// already fronted by an approval overlay, whereas revealing a key is
// irreversible the moment it is on screen. Turning this off does not
// make a secret free to read — it moves the prompt to the master
// password, which is the stronger credential, not a weaker one.
requirePinForReveal: cfg.requirePinForReveal !== false,
}; };
}); });
api.onMessage("securitySet", (p, m) => { api.onMessage("securitySet", (p, m) => {
@ -2059,10 +2137,12 @@ function registerPanelMessages(api) {
const cur = api.storage.get("aegis/security/v1", {}) || {}; const cur = api.storage.get("aegis/security/v1", {}) || {};
const next = { ...cur }; const next = { ...cur };
if (p && typeof p.requirePinForSending === "boolean") next.requirePinForSending = p.requirePinForSending; if (p && typeof p.requirePinForSending === "boolean") next.requirePinForSending = p.requirePinForSending;
if (p && typeof p.requirePinForReveal === "boolean") next.requirePinForReveal = p.requirePinForReveal;
api.storage.set("aegis/security/v1", next); api.storage.set("aegis/security/v1", next);
return { return {
hasPin: !!api.storage.get("aegis/pin/v1", null), hasPin: !!api.storage.get("aegis/pin/v1", null),
requirePinForSending: !!next.requirePinForSending, requirePinForSending: !!next.requirePinForSending,
requirePinForReveal: next.requirePinForReveal !== false,
}; };
}); });

View file

@ -975,6 +975,19 @@
<label class="switch"><input type="checkbox" id="gsRequirePin"><span></span></label> <label class="switch"><input type="checkbox" id="gsRequirePin"><span></span></label>
</div> </div>
</div> </div>
<!-- Turning this OFF does not make a secret free to read: it moves
the prompt from the PIN to the master password, which is the
stronger credential. There is no "ask me nothing" setting for
showing a key, by design. -->
<div class="gline" id="gsRequirePinRevealLine" hidden>
<div class="glabel">
Use PIN to show secret keys
<span class="ghint">On, Aegis takes your PIN before showing a wallet's secret key, and asks for the master password after three wrong tries. Off, it asks for the master password straight away.</span>
</div>
<div class="gactions">
<label class="switch"><input type="checkbox" id="gsRequirePinReveal"><span></span></label>
</div>
</div>
</div> </div>
</div><!-- /security section (multi-sig card lives inside it below) --> </div><!-- /security section (multi-sig card lives inside it below) -->

View file

@ -37,10 +37,12 @@ let sendAsset = null;
// header. Cleared whenever a fresh render is triggered by a wallet change // header. Cleared whenever a fresh render is triggered by a wallet change
// so the strip snaps back to the summary. // so the strip snaps back to the summary.
let stripView = { mode: "coins", groupKey: null }; let stripView = { mode: "coins", groupKey: null };
// Cached security state ({ hasPin, requirePinForSending }). Populated on // Cached security state ({ hasPin, requirePinForSending, requirePinForReveal }).
// startup and refreshed after any pin/security invoke — used both by the // Populated on startup and refreshed after any pin/security invoke — used by
// lock screen (PIN vs. password) and the Settings General card. // the lock screen (PIN vs. password), the Settings General card, and the
let securityState = { hasPin: false, requirePinForSending: false }; // reveal gate. requirePinForReveal defaults TRUE, here and in the host, so a
// failed read never lands on the permissive setting.
let securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true };
let securityLoaded = false; let securityLoaded = false;
// Cached session config: whether the vault stays unlocked across Theseus // Cached session config: whether the vault stays unlocked across Theseus
// restarts (safeStorage-backed) and how many idle minutes trigger an // restarts (safeStorage-backed) and how many idle minutes trigger an
@ -344,7 +346,7 @@ async function refreshSecurityState() {
try { try {
securityState = await S.invoke("securityGet"); securityState = await S.invoke("securityGet");
securityLoaded = true; securityLoaded = true;
} catch { securityState = { hasPin: false, requirePinForSending: false }; securityLoaded = true; } } catch { securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true }; securityLoaded = true; }
return securityState; return securityState;
} }
async function refreshSessionState() { async function refreshSessionState() {
@ -1092,6 +1094,11 @@ function openWalletManageModal(w) {
</label> </label>
${wcOk ? "" : `<div class="hint" style="margin-top:4px">${esc(wcWhy)}</div>`} ${wcOk ? "" : `<div class="hint" style="margin-top:4px">${esc(wcWhy)}</div>`}
</div> </div>
<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:4px">Secret key</div>
<div class="hint" style="margin-bottom:8px">Everything needed to spend this wallet elsewhere. Aegis asks for your PIN (or master password) first.</div>
<button class="btn" id="mwReveal" type="button">Show secret key…</button>
</div>
${canPromote ? `<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px"> ${canPromote ? `<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:4px">WizardConnect</div> <div class="lbl" style="margin-bottom:4px">WizardConnect</div>
<div class="hint" style="margin-bottom:8px">This wallet came from a single private key, so it can't pair with dapps — WizardConnect hands them an xpub to derive addresses from, and one key is not a key tree. Promoting derives a proper wallet from your vault and sweeps this one into it.</div> <div class="hint" style="margin-bottom:8px">This wallet came from a single private key, so it can't pair with dapps — WizardConnect hands them an xpub to derive addresses from, and one key is not a key tree. Promoting derives a proper wallet from your vault and sweeps this one into it.</div>
@ -1138,6 +1145,7 @@ function openWalletManageModal(w) {
render(); render();
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
}); });
overlay.querySelector("#mwReveal").addEventListener("click", () => openRevealSecretModal(w));
// Promote: preview the sweep (costed without creating anything), confirm // Promote: preview the sweep (costed without creating anything), confirm
// with the real numbers, then create + sweep in one host call. The confirm // with the real numbers, then create + sweep in one host call. The confirm
// carries the amounts because this moves the wallet's entire balance. // carries the amounts because this moves the wallet's entire balance.
@ -4368,6 +4376,10 @@ async function renderGeneralSecurity() {
: "Off — Aegis asks for the master password every time."; : "Off — Aegis asks for the master password every time.";
if (line) line.hidden = !hasPin; if (line) line.hidden = !hasPin;
if (rp) rp.checked = !!securityState.requirePinForSending; if (rp) rp.checked = !!securityState.requirePinForSending;
// Both PIN policies are meaningless without a PIN to use.
const revLine = $("gsRequirePinRevealLine"), rpr = $("gsRequirePinReveal");
if (revLine) revLine.hidden = !hasPin;
if (rpr) rpr.checked = !!securityState.requirePinForReveal;
renderSessionSettings(); renderSessionSettings();
} }
@ -4479,6 +4491,16 @@ $("gsRequirePin") && $("gsRequirePin").addEventListener("change", async () => {
aegisAlert("Could not save setting: " + cleanErr(e)); aegisAlert("Could not save setting: " + cleanErr(e));
} }
}); });
$("gsRequirePinReveal") && $("gsRequirePinReveal").addEventListener("change", async () => {
const on = $("gsRequirePinReveal").checked;
try {
securityState = await S.invoke("securitySet", { requirePinForReveal: on });
renderGeneralSecurity();
} catch (e) {
$("gsRequirePinReveal").checked = !on;
aegisAlert("Could not save setting: " + cleanErr(e));
}
});
$("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => { $("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => {
// Route through the addon so it can pass the section slug back to // Route through the addon so it can pass the section slug back to
// Theseus (main-process gates section-hint validation). // Theseus (main-process gates section-hint validation).
@ -4614,6 +4636,196 @@ function promptMasterPassword({ title, subtitle }) {
}); });
} }
// What each secret form actually IS, and where it can actually be restored.
// This copy matters more than it looks: none of these are a BIP39 recovery
// phrase, because Aegis never stores one. An import converts the words to a
// seed and throws the words away; a vault wallet is HKDF(vault root, purpose)
// and never had words. Someone who writes down what we show here and believes
// it is a 12-word phrase has not backed anything up, so each form says what
// it is in its own name and the note says what to do with it.
const SECRET_FORMS = {
wif: {
title: "Private key (WIF)",
note: "This single key controls this one address and nothing else. Any Bitcoin Cash wallet that accepts a WIF key can import it.",
},
privhex: {
title: "Private key (hex)",
note: "This single key controls this one account. Most ETH / TRX / SOL wallets accept a raw hex private key.",
},
seed: {
title: "Wallet seed (hex)",
note: "There is no word list to show. You imported a recovery phrase, and Aegis converted it to this seed and discarded the words — that conversion is one-way, so the phrase cannot be recovered from here or from anywhere else in Aegis. Keep the original phrase wherever you first wrote it down. This seed plus the derivation path below is a complete backup of the wallet, and Aegis can take it back under Add → Import → Seed (hex).",
},
vault: {
title: "Wallet key (hex)",
note: "This wallet has no recovery phrase of its own — it is derived from your Theseus vault, so your real backup is the vault's master password. The key below restores this one wallet via Add → Import → Seed (hex), and the account xprv, where shown, is accepted by most other HD wallets.",
},
};
// Reveal one wallet's secret. Gated by authorizeForSecret, hidden until
// asked for a second time, and never left on screen after the modal closes.
async function openRevealSecretModal(w) {
// Two shapes reach here: a wallet summary from the manage modal, which
// keys the id as `id`, and state.selected from the Settings buttons, which
// keys it as `walletId`. Accept both rather than silently doing nothing.
const walletId = w && (w.id || w.walletId);
if (!walletId) return;
const pw = await authorizeForSecret(`Reveal the secret key for "${(w && w.label) || "this wallet"}".`);
if (!pw) return;
let r;
try { r = await S.invoke("revealSecret", { walletId, masterPassword: pw }); }
catch (e) { await aegisAlert(cleanErr(e), { title: "Could not reveal", icon: "⚠️" }); return; }
const form = SECRET_FORMS[r.form] || { title: "Secret", note: "" };
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px";
const field = (label, value, danger) => value
? `<div class="lbl" style="margin-top:8px">${esc(label)}</div>
<div class="mono" style="word-break:break-all;${danger ? "color:var(--danger)" : ""}">${esc(value)}</div>`
: "";
overlay.innerHTML = `
<div style="width:min(94vw,420px);max-height:92vh;overflow-y:auto;background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<div style="font-weight:600;flex:1">${esc(form.title)}</div>
<button class="btn sm" id="rvClose" type="button">✕</button>
</div>
<div class="hint" style="margin-bottom:10px">${esc(r.label || "")} · ${esc(r.coinLabel || "")} · ${esc(r.networkLabel || "")}</div>
<div class="msg err" style="margin-bottom:10px">Anyone who sees this can spend everything in this wallet. Nobody legitimate will ever ask you for it — not support, not Silent Mode.</div>
<div class="hint" style="margin-bottom:10px">${esc(form.note)}</div>
<div id="rvHidden">
<div class="actions"><button class="btn danger" id="rvShow" type="button">Show the key</button></div>
</div>
<div id="rvShown" hidden>
${field(form.title, r.secret, true)}
${field("Derivation path", r.path || r.accountPath, false)}
${field("Account private key (xprv)", r.xprv, true)}
${field("Account xpub (safe to share)", r.xpub, false)}
${field("Address", r.address, false)}
<div class="actions" style="margin-top:12px;gap:6px">
<button class="btn" id="rvCopy" type="button">Copy key</button>
<button class="btn" id="rvHide" type="button">Hide</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
// Wipe the rendered secret out of the DOM on the way out rather than
// relying on the node being dropped — the modal is the only place it
// exists in the renderer, so clearing it is cheap and exact.
const close = () => {
try { overlay.querySelector("#rvShown").innerHTML = ""; } catch {}
try { overlay.remove(); } catch {}
};
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector("#rvClose").addEventListener("click", close);
overlay.querySelector("#rvShow").addEventListener("click", () => {
overlay.querySelector("#rvHidden").hidden = true;
overlay.querySelector("#rvShown").hidden = false;
});
overlay.querySelector("#rvHide").addEventListener("click", close);
overlay.querySelector("#rvCopy").addEventListener("click", async (e) => {
try { await navigator.clipboard.writeText(r.secret); flash(e.currentTarget, "Copied"); }
catch { await aegisAlert("Could not reach the clipboard."); }
});
}
// How many wrong PINs before a sensitive reveal stops asking for the PIN and
// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose:
// someone fumbling their own PIN gets a way through that does not cost them a
// 15-minute lockout, and someone guessing is pushed onto the credential that
// is actually hard to guess. The global counter is NOT reset on the way
// across, so guesses still accumulate toward the lockout.
const REVEAL_PIN_MAX_FAILS = 3;
// Prove entitlement to see a secret, and hand back the master password —
// which is what the host verifies before it parts with anything. The PIN blob
// wraps that same password, so both routes end at the same proof and the host
// never has to take the panel's word for it.
//
// Returns the master password, or null if the user backed out.
async function authorizeForSecret(subtitle) {
if (!securityLoaded) await refreshSecurityState();
const usePin = securityState.requirePinForReveal && securityState.hasPin;
if (!usePin) {
// No PIN configured, or the user turned the PIN prompt off. Either way
// the master password is the gate — never nothing.
return promptMasterPassword({ title: "Confirm master password", subtitle });
}
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
// Locked out of the PIN, but the password is a separate credential and
// the lockout exists to stop PIN guessing, not to lock the owner out.
return promptMasterPassword({
title: "Confirm master password",
subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(),
});
}
const pin = await capturePinForSecret(subtitle);
if (pin === null) return null; // cancelled
if (pin === "__fallback__") {
return promptMasterPassword({
title: "Confirm master password",
subtitle: `${REVEAL_PIN_MAX_FAILS} wrong PIN attempts. ${subtitle || ""}`.trim(),
});
}
return pin;
}
// PIN pad that resolves with the DECRYPTED MASTER PASSWORD on success, null
// on cancel, or "__fallback__" once the user has burned REVEAL_PIN_MAX_FAILS
// attempts. Separate from verifyPinInteractively because that one only
// answers yes/no and throws the password away.
function capturePinForSecret(subtitle) {
return new Promise((resolve) => {
const wrap = document.createElement("div");
wrap.className = "pinmodal";
wrap.innerHTML = `
<div class="pincard">
<h2>Confirm with PIN</h2>
<div class="pinsub" id="rsSub">${esc(subtitle || "")}</div>
<div class="pinpad">
<div class="pindots" id="rsDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pinkeys" id="rsKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
<button data-k="0">0</button>
<button class="util" data-k="back">⌫</button>
</div>
<div class="pinerr" id="rsErr"></div>
</div>
<div class="pinactions">
<button class="btn" id="rsCancel" type="button">Cancel</button>
<button class="btn" id="rsUsePw" type="button">Use master password</button>
</div>
</div>`;
document.body.appendChild(wrap);
const done = (v) => { try { wrap.remove(); } catch {} resolve(v); };
wrap.querySelector("#rsCancel").addEventListener("click", () => done(null));
wrap.querySelector("#rsUsePw").addEventListener("click", () => done("__fallback__"));
let tries = 0;
setupPinPad({
dots: $("rsDots"), keys: $("rsKeys"), err: $("rsErr"),
onComplete: async (pin) => {
try {
const blob = await S.invoke("pinBlobGet");
if (!blob) throw new Error("no PIN configured");
const master = await pinDecryptMaster(pin, blob);
await S.invoke("pinFailReset").catch(() => {});
done(master);
return "ok";
} catch (e) {
tries++;
// Keep feeding the shared counter: these are real PIN guesses and
// they should still count toward the 15 min lockout.
await S.invoke("pinFailInc").catch(() => ({ count: 0 }));
if (tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; }
const left = REVEAL_PIN_MAX_FAILS - tries;
$("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`;
return "reset";
}
},
});
});
}
// Ask the user to prove they know the PIN. Uses the same lockout counter // Ask the user to prove they know the PIN. Uses the same lockout counter
// as the unlock flow so an attacker can't drain guesses via a spammed // as the unlock flow so an attacker can't drain guesses via a spammed
// Send button. Returns true on match, false on cancel / lockout / bad PIN. // Send button. Returns true on match, false on cancel / lockout / bad PIN.
@ -4851,10 +5063,10 @@ $("showXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); } try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); }
catch (e) { $("recovery").textContent = cleanErr(e); } catch (e) { $("recovery").textContent = cleanErr(e); }
}); });
$("showXprv").addEventListener("click", async () => { // Every "show the secret" button goes through the one gated path. They used
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } // to call recovery({reveal:true}), which handed back the xprv behind nothing
catch (e) { $("recovery").textContent = cleanErr(e); } // but an approval click and refused imported wallets outright.
}); $("showXprv").addEventListener("click", () => openRevealSecretModal(sel()));
function recoveryHtml(r) { function recoveryHtml(r) {
let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`; let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`;
if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`; if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`;
@ -4879,14 +5091,7 @@ $("applyWalletdUrl").addEventListener("click", async () => {
settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied"); settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
}); });
$("showScSeed").addEventListener("click", async () => { $("showScSeed").addEventListener("click", () => openRevealSecretModal(sel()));
try {
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
$("scRecovery").innerHTML =
`<div class="lbl">First address (index 0)</div><div class="mono">${esc(r.xpub || "")}</div>` +
(r.xprv ? `<div class="lbl">Wallet seed (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
} catch (e) { $("scRecovery").textContent = cleanErr(e); }
});
// Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc": // Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc":
// the DOM IDs are #set<Prefix>Family + #set<Prefix>Path. // the DOM IDs are #set<Prefix>Family + #set<Prefix>Path.
@ -4931,10 +5136,7 @@ $("showBtcXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); } try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("btcRecovery").textContent = cleanErr(e); } catch (e) { $("btcRecovery").textContent = cleanErr(e); }
}); });
$("showBtcXprv").addEventListener("click", async () => { $("showBtcXprv").addEventListener("click", () => openRevealSecretModal(sel()));
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
});
// ETH / SOL: RPC URL. // ETH / SOL: RPC URL.
$("applyEthRpc").addEventListener("click", async () => { $("applyEthRpc").addEventListener("click", async () => {
@ -4951,31 +5153,13 @@ $("applySolRpc").addEventListener("click", async () => {
settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied"); settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied");
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
}); });
$("showEthKey").addEventListener("click", async () => { $("showEthKey").addEventListener("click", () => openRevealSecretModal(sel()));
try { $("showSolKey").addEventListener("click", () => openRevealSecretModal(sel()));
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
$("ethRecovery").innerHTML =
`<div class="lbl">Address</div><div class="mono">${esc(sel().address || "")}</div>` +
`<div class="lbl">Public key (uncompressed hex)</div><div class="mono">${esc(r.xpub || "")}</div>` +
(r.xprv ? `<div class="lbl">Private key (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
} catch (e) { $("ethRecovery").textContent = cleanErr(e); }
});
$("showSolKey").addEventListener("click", async () => {
try {
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
$("solRecovery").innerHTML =
`<div class="lbl">Address (public key, base58)</div><div class="mono">${esc(r.xpub || "")}</div>` +
(r.xprv ? `<div class="lbl">Wallet seed (hex, 32 bytes)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
} catch (e) { $("solRecovery").textContent = cleanErr(e); }
});
$("showDgbXpub").addEventListener("click", async () => { $("showDgbXpub").addEventListener("click", async () => {
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); } try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("dgbRecovery").textContent = cleanErr(e); } catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
}); });
$("showDgbXprv").addEventListener("click", async () => { $("showDgbXprv").addEventListener("click", () => openRevealSecretModal(sel()));
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); }
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
});
// ---- WizardConnect (BCH only) --------------------------------------------- // ---- WizardConnect (BCH only) ---------------------------------------------
function renderWcSites() { function renderWcSites() {