Aegis: the PIN pads use the one Theseus PIN when the host offers it
On a host with api.vault.pin, Aegis no longer keeps a PIN of its own: its lock-screen, reveal and transaction pads send the digits to the Theseus vault PIN, which is checked in main against the one strike counter Settings, the unlock prompt and Pithos also use. The vault is opened there, and the panel receives a single-use proof (two minutes) where it used to receive the master password; vaultUnlock, revealSecret and pinGateSatisfied accept it, still bound to the request it was entered for. The master password no longer passes through Aegis or its panel for a PIN entry. An existing Aegis PIN moves to the vault PIN on its first correct entry. If Theseus already has a different PIN, the user is asked once which one to keep. Setting and removing the PIN act on the vault PIN, and Settings says that it is shared. Hosts without the API (Theseus 0.3.74-0.3.76) keep Aegis's own PIN exactly as before.
This commit is contained in:
parent
9d6d8c3cc5
commit
70b35e2520
2 changed files with 157 additions and 17 deletions
|
|
@ -1355,6 +1355,57 @@ async function tryTpmKey(api, pin) {
|
|||
try { return await tpmPin.create(pin); }
|
||||
catch (e) { tpmUnavailableThisBoot = true; api.log("PIN: no TPM key:", e?.message || e); return null; }
|
||||
}
|
||||
// ---- one PIN: the Theseus vault PIN ---------------------------------------
|
||||
// On a host with api.vault.pin (features.vaultPin) there is one PIN in
|
||||
// Theseus: the vault PIN, checked in Theseus main against one strike
|
||||
// counter, also used by Settings, the unlock prompt and Pithos. Aegis keeps
|
||||
// its PIN pads and their wording and sends the digits there; the host opens
|
||||
// the vault and answers only whether the PIN was right, so the master
|
||||
// password never reaches Aegis. What the panel gets back in place of the
|
||||
// password is a single-use proof (mintPinProof), which the handlers that
|
||||
// used to verify a password accept instead. On older hosts (0.3.74-0.3.76)
|
||||
// Aegis keeps its own PIN blob (aegis/pin/v1) exactly as before.
|
||||
const hostPinApi = (api) => (api && api.features && api.features.vaultPin && api.vault && api.vault.pin) ? api.vault.pin : null;
|
||||
let hostPinCache = { pinSet: false, hardware: null, storable: true, at: 0 };
|
||||
async function refreshHostPin(api) {
|
||||
const hp = hostPinApi(api);
|
||||
if (!hp) return null;
|
||||
try { const st = await hp.status(); hostPinCache = { ...st, at: Date.now() }; } catch (e) { api.log("vault PIN status:", e?.message || e); }
|
||||
return hostPinCache;
|
||||
}
|
||||
// Is there a PIN to ask for? Aegis's own (old hosts, or not yet migrated)
|
||||
// or the vault PIN.
|
||||
function hasPinNow(api) {
|
||||
return !!api.storage.get(PIN_BLOB_KEY, null) || (!!hostPinApi(api) && !!hostPinCache.pinSet);
|
||||
}
|
||||
const PIN_PROOF_TTL_MS = 120_000;
|
||||
const pinProofs = new Map(); // token -> expiry
|
||||
function mintPinProof() {
|
||||
const t = "pinproof:" + nodeCrypto.randomBytes(24).toString("hex");
|
||||
for (const [k, exp] of pinProofs) if (exp < Date.now()) pinProofs.delete(k);
|
||||
pinProofs.set(t, Date.now() + PIN_PROOF_TTL_MS);
|
||||
return t;
|
||||
}
|
||||
function takePinProof(t) {
|
||||
const exp = pinProofs.get(t);
|
||||
if (!exp) return false;
|
||||
pinProofs.delete(t);
|
||||
return exp > Date.now();
|
||||
}
|
||||
// Where a handler used to check a master password: accept either the
|
||||
// password (checked by the vault) or a fresh PIN proof minted above.
|
||||
// Answers "pin" or "master"; throws when neither holds.
|
||||
async function verifyPasswordOrProof(api, pw) {
|
||||
const s = String(pw || "");
|
||||
if (s.startsWith("pinproof:")) {
|
||||
if (!takePinProof(s)) throw new Error("PIN proof rejected");
|
||||
return "pin";
|
||||
}
|
||||
await api.vault.lifecycle.unlock(s);
|
||||
noteMasterVerified(api);
|
||||
return "master";
|
||||
}
|
||||
|
||||
function pinFails(api) {
|
||||
const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
|
||||
const last = Number(api.storage.get("aegis/pin/failLast", 0)) || 0;
|
||||
|
|
@ -1399,6 +1450,7 @@ function requirePanelTxPin() {
|
|||
throw new Error("PIN required — confirm your PIN to continue");
|
||||
}
|
||||
async function requireDappTxPin(origin, what) {
|
||||
await refreshHostPin(ctx.api);
|
||||
if (!txPinOwed()) return;
|
||||
const req = { id: nodeCrypto.randomBytes(8).toString("hex"), origin: String(origin || ""), what: String(what || "transaction"), at: Date.now(), cleared: false };
|
||||
pendingPinRequests.set(req.id, req);
|
||||
|
|
@ -1514,8 +1566,8 @@ function registerPanelMessages(api) {
|
|||
api.onMessage("vaultUnlock", async (p, m) => {
|
||||
fromPanel(m);
|
||||
const pw = String(p && p.masterPassword || "");
|
||||
await api.vault.lifecycle.unlock(pw);
|
||||
noteMasterVerified(api);
|
||||
// A PIN proof means Theseus has already opened the vault with the PIN.
|
||||
await verifyPasswordOrProof(api, pw);
|
||||
await mountAllWallets();
|
||||
return fullState();
|
||||
});
|
||||
|
|
@ -1923,6 +1975,7 @@ function registerPanelMessages(api) {
|
|||
});
|
||||
api.onMessage("sendToken", async (p, m) => {
|
||||
fromPanel(m);
|
||||
await refreshHostPin(api);
|
||||
requirePanelTxPin();
|
||||
// Same rule as send: the token send names the wallet it was reviewed for.
|
||||
if (!p || !p.walletId || String(p.walletId) !== selectedWalletId()) {
|
||||
|
|
@ -1952,6 +2005,7 @@ function registerPanelMessages(api) {
|
|||
// Execute a send with approval overlay.
|
||||
api.onMessage("send", async (p, m) => {
|
||||
fromPanel(m);
|
||||
await refreshHostPin(api);
|
||||
requirePanelTxPin();
|
||||
// The panel names the wallet its form was built for. If the selection
|
||||
// moved since (another surface, a late state push), refuse rather than
|
||||
|
|
@ -2055,6 +2109,7 @@ function registerPanelMessages(api) {
|
|||
|
||||
api.onMessage("consolidateIntoSelected", async (p, m) => {
|
||||
fromPanel(m);
|
||||
await refreshHostPin(api);
|
||||
requirePanelTxPin();
|
||||
const destId = selectedWalletId();
|
||||
if (!destId) throw new Error("no wallet selected");
|
||||
|
|
@ -2221,6 +2276,7 @@ function registerPanelMessages(api) {
|
|||
|
||||
api.onMessage("promoteToHd", async (p, m) => {
|
||||
fromPanel(m);
|
||||
await refreshHostPin(api);
|
||||
requirePanelTxPin();
|
||||
const { entry, rt } = promotableImport(p && p.walletId);
|
||||
const dest = await createVaultWallet({
|
||||
|
|
@ -2295,8 +2351,9 @@ function registerPanelMessages(api) {
|
|||
fromPanel(m);
|
||||
const pw = String((p && p.masterPassword) || "");
|
||||
if (!pw) throw new Error("master password required");
|
||||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
try { await verifyPasswordOrProof(api, pw); }
|
||||
catch (e) {
|
||||
if (/PIN proof rejected/.test(e?.message || "")) throw new Error("PIN proof rejected — enter the PIN again");
|
||||
// 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.
|
||||
|
|
@ -2731,12 +2788,46 @@ function registerPanelMessages(api) {
|
|||
// the opaque blob + a small policy object in and out of api.storage.
|
||||
// Set or replace the PIN. The master password is checked against the vault
|
||||
// first, and the blob is built here, so the panel never holds one.
|
||||
// Aegis's own PIN, gone: blob, its TPM key, its counters.
|
||||
const dropOwnPin = () => {
|
||||
const old = openPinBlob(api);
|
||||
if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {});
|
||||
api.storage.set(PIN_BLOB_KEY, null);
|
||||
api.storage.set("aegis/pin/failCount", 0);
|
||||
api.storage.set("aegis/pin/failLast", 0);
|
||||
};
|
||||
// A PIN entered correctly against Aegis's own blob while Theseus already
|
||||
// has a different vault PIN: the user is asked once which one to keep.
|
||||
let pendingPinMigration = null; // { pin, pw, until }
|
||||
api.onMessage("pinMigrateResolve", async (p, m) => {
|
||||
fromPanel(m);
|
||||
const hp = hostPinApi(api);
|
||||
const mig = pendingPinMigration;
|
||||
pendingPinMigration = null;
|
||||
if (!hp || !mig || mig.until < Date.now()) throw new Error("nothing to migrate");
|
||||
if (p && p.replace === true) await hp.set(mig.pin, mig.pw);
|
||||
dropOwnPin();
|
||||
await refreshHostPin(api);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
api.onMessage("pinSet", async (p, m) => {
|
||||
fromPanel(m);
|
||||
const pin = String((p && p.pin) || "");
|
||||
const pw = String((p && p.masterPassword) || "");
|
||||
if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits");
|
||||
if (!pw) throw new Error("master password required");
|
||||
const hp = hostPinApi(api);
|
||||
if (hp) {
|
||||
// The one PIN: Theseus checks the master password and seals the PIN.
|
||||
let r;
|
||||
try { r = await hp.set(pin, pw); }
|
||||
catch (e) { throw new Error(/wrong master password/.test(e?.message || "") ? "wrong master password" : (e?.message || String(e))); }
|
||||
noteMasterVerified(api);
|
||||
dropOwnPin();
|
||||
await refreshHostPin(api);
|
||||
return { ok: true, hardware: r && r.hardware || null, unified: true };
|
||||
}
|
||||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
catch { throw new Error("wrong master password"); }
|
||||
noteMasterVerified(api);
|
||||
|
|
@ -2757,6 +2848,16 @@ function registerPanelMessages(api) {
|
|||
api.onMessage("pinUnwrap", async (p, m) => {
|
||||
fromPanel(m);
|
||||
const pin = String((p && p.pin) || "");
|
||||
const hp = hostPinApi(api);
|
||||
if (hp && !api.storage.get(PIN_BLOB_KEY, null)) {
|
||||
// The one PIN. Theseus counts the guess, opens the vault on success,
|
||||
// and keeps the master password to itself.
|
||||
const r = await hp.unlock(pin);
|
||||
await refreshHostPin(api);
|
||||
if (r && r.ok) return { ok: true, masterPassword: mintPinProof() };
|
||||
if (r && r.code === "no-pin") throw new Error("no PIN is set");
|
||||
return { ok: false, remaining: Number(r?.remaining) || 0, lockedMs: Number(r?.lockedMs) || 0, error: r?.error || undefined };
|
||||
}
|
||||
const blob = openPinBlob(api);
|
||||
if (!blob) throw new Error("no PIN is set");
|
||||
const st = pinFails(api);
|
||||
|
|
@ -2809,15 +2910,34 @@ function registerPanelMessages(api) {
|
|||
}
|
||||
} catch (e) { api.log("PIN re-wrap:", e?.message || e); }
|
||||
}
|
||||
if (hp) {
|
||||
// Migrate the old Aegis PIN to the one PIN on its first correct entry.
|
||||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
catch { return { ok: false, remaining: 0, lockedMs: 0, error: "Your PIN is out of date. Enter the master password, then set a new PIN." }; }
|
||||
const hs = await refreshHostPin(api);
|
||||
if (!hs || !hs.pinSet) {
|
||||
try { await hp.set(pin, pw); dropOwnPin(); await refreshHostPin(api); api.log("Aegis PIN moved to the Theseus vault PIN"); }
|
||||
catch (e) { api.log("PIN migration:", e?.message || e); }
|
||||
return { ok: true, masterPassword: mintPinProof(), migrated: true };
|
||||
}
|
||||
pendingPinMigration = { pin, pw, until: Date.now() + PIN_PROOF_TTL_MS };
|
||||
return { ok: true, masterPassword: mintPinProof(), pinConflict: true };
|
||||
}
|
||||
return { ok: true, masterPassword: pw };
|
||||
});
|
||||
api.onMessage("pinStatus", (_p, m) => {
|
||||
api.onMessage("pinStatus", async (_p, m) => {
|
||||
fromPanel(m);
|
||||
if (hostPinApi(api) && !api.storage.get(PIN_BLOB_KEY, null)) {
|
||||
const hs = (await refreshHostPin(api)) || {};
|
||||
return { hasPin: !!hs.pinSet, fails: hs.fails || 0, last: hs.last || 0, maxFails: hs.maxFails || PIN_MAX_FAILS, lockedMs: hs.lockedMs || 0, unified: true, ...pinProtection() };
|
||||
}
|
||||
const f = pinFails(api);
|
||||
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs, ...pinProtection() };
|
||||
});
|
||||
api.onMessage("pinBlobClear", (_p, m) => {
|
||||
api.onMessage("pinBlobClear", async (_p, m) => {
|
||||
fromPanel(m);
|
||||
const hp = hostPinApi(api);
|
||||
if (hp) { try { await hp.clear(); } catch (e) { api.log("vault PIN clear:", e?.message || e); } await refreshHostPin(api); }
|
||||
const old = openPinBlob(api);
|
||||
if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {});
|
||||
api.storage.set("aegis/pin/v1", null);
|
||||
|
|
@ -2856,9 +2976,11 @@ function registerPanelMessages(api) {
|
|||
function pinProtection() {
|
||||
const blob = api.storage.get(PIN_BLOB_KEY, null) ? openPinBlob(api) : null;
|
||||
const exposed = api.storage.get(PIN_EXPOSED_KEY, null);
|
||||
const host = hostPinApi(api) && !blob ? hostPinCache : null;
|
||||
return {
|
||||
pinHardware: blob ? (blob.hw ? "tpm" : "none") : null,
|
||||
pinStorable: osSealUsable(safeStorageOr(api)),
|
||||
pinHardware: blob ? (blob.hw ? "tpm" : "none") : host ? (host.pinSet ? host.hardware || "none" : null) : null,
|
||||
pinStorable: host ? host.storable !== false : osSealUsable(safeStorageOr(api)),
|
||||
pinUnified: !!hostPinApi(api),
|
||||
pinLegacyExposure: exposed && !exposed.dismissed ? { at: exposed.at } : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -2877,7 +2999,7 @@ function registerPanelMessages(api) {
|
|||
// panel reloads freely and must not be the thing that remembers whether a
|
||||
// gate was already satisfied.
|
||||
function pinNeeded(event) {
|
||||
if (!api.storage.get("aegis/pin/v1", null)) return { needPin: false, reason: "no-pin" };
|
||||
if (!hasPinNow(api)) return { needPin: false, reason: "no-pin" };
|
||||
const on = pinPolicy();
|
||||
const g = pinGate();
|
||||
if (on.interval) {
|
||||
|
|
@ -2895,8 +3017,9 @@ function registerPanelMessages(api) {
|
|||
return { needPin: false, reason: "satisfied" };
|
||||
}
|
||||
|
||||
api.onMessage("pinGateStatus", (p, m) => {
|
||||
api.onMessage("pinGateStatus", async (p, m) => {
|
||||
fromPanel(m);
|
||||
await refreshHostPin(api);
|
||||
const event = String(p && p.event || "panel-load");
|
||||
return { ...pinNeeded(event), event, policy: pinPolicy() };
|
||||
});
|
||||
|
|
@ -2910,9 +3033,8 @@ function registerPanelMessages(api) {
|
|||
const pw = String((p && p.masterPassword) || "");
|
||||
if (!pw) throw new Error("PIN proof required");
|
||||
if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN");
|
||||
try { await api.vault.lifecycle.unlock(pw); }
|
||||
try { await verifyPasswordOrProof(api, pw); }
|
||||
catch { throw new Error("PIN proof rejected"); }
|
||||
noteMasterVerified(api);
|
||||
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID });
|
||||
// What this proof clears (see requireDappTxPin).
|
||||
const forRequest = String((p && p.requestId) || "");
|
||||
|
|
@ -2938,11 +3060,12 @@ function registerPanelMessages(api) {
|
|||
// happens to open.
|
||||
try { openPinBlob(api); } catch {}
|
||||
|
||||
api.onMessage("securityGet", (_p, m) => {
|
||||
api.onMessage("securityGet", async (_p, m) => {
|
||||
fromPanel(m);
|
||||
await refreshHostPin(api);
|
||||
const cfg = api.storage.get("aegis/security/v1", {}) || {};
|
||||
return {
|
||||
hasPin: !!api.storage.get("aegis/pin/v1", null),
|
||||
hasPin: hasPinNow(api),
|
||||
pinOn: pinPolicy(),
|
||||
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
|
||||
requirePinForSending: !!cfg.requirePinForSending,
|
||||
|
|
@ -2977,7 +3100,7 @@ function registerPanelMessages(api) {
|
|||
}
|
||||
api.storage.set("aegis/security/v1", next);
|
||||
return {
|
||||
hasPin: !!api.storage.get("aegis/pin/v1", null),
|
||||
hasPin: hasPinNow(api),
|
||||
pinOn: pinPolicy(),
|
||||
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
|
||||
requirePinForSending: !!next.requirePinForSending,
|
||||
|
|
@ -4614,6 +4737,8 @@ module.exports = {
|
|||
};
|
||||
migrateLegacyStorage(api);
|
||||
registerPanelMessages(api);
|
||||
// Learn whether the one PIN is set before the first gate is decided.
|
||||
refreshHostPin(api).catch(() => {});
|
||||
registerPageMessages(api);
|
||||
launchStartAsked = null;
|
||||
syncLaunchStart(api);
|
||||
|
|
|
|||
|
|
@ -313,6 +313,18 @@ const PIN_MAX_FAILS = 5;
|
|||
const pinTry = async (pin) => {
|
||||
const r = await S.invoke("pinUnwrap", { pin: String(pin) });
|
||||
if (r && !r.ok && r.error) throw new Error(r.error);
|
||||
// Theseus now has one PIN for everything. An Aegis PIN entered correctly
|
||||
// while Theseus already has a different one: ask once which to keep.
|
||||
if (r && r.ok && r.pinConflict) {
|
||||
const replace = await aegisConfirm({
|
||||
title: "Use this PIN everywhere?",
|
||||
confirmLabel: "Use this PIN",
|
||||
cancelLabel: "Keep the Theseus PIN",
|
||||
body: "Theseus and Aegis now share one PIN. Theseus already has a PIN of its own. Use the PIN you just entered for both (Settings, Pithos and Aegis), or keep the Theseus PIN — your old Aegis PIN then stops working.",
|
||||
});
|
||||
try { await S.invoke("pinMigrateResolve", { replace: !!replace }); } catch {}
|
||||
try { await refreshSecurityState(); } catch {}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
async function pinLockoutRemainingMs() {
|
||||
|
|
@ -4755,13 +4767,14 @@ async function renderGeneralSecurity() {
|
|||
if (chg) chg.hidden = !hasPin;
|
||||
if (rm) rm.hidden = !hasPin;
|
||||
// Say what the PIN really protects against on this machine.
|
||||
if (hint) hint.textContent = hasPin
|
||||
const shared = securityState.pinUnified ? " It is the Theseus PIN: the same PIN unlocks Settings › Passwords and Pithos." : "";
|
||||
if (hint) hint.textContent = (hasPin
|
||||
? (securityState.pinHardware === "tpm"
|
||||
? "On — a 6-digit PIN unlocks Aegis. It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
|
||||
: "On — a 6-digit PIN unlocks Aegis. This computer has no usable security chip, so the PIN only stops casual use: anything that runs as your Windows account, or a copy of this disk with your Windows password, can find the PIN in minutes and with it your master password.")
|
||||
: (securityState.pinStorable === false
|
||||
? "Off — this system has no protected keystore, so Aegis cannot store a PIN safely and asks for the master password every time."
|
||||
: "Off — Aegis asks for the master password every time.");
|
||||
: "Off — Aegis asks for the master password every time.")) + (hasPin ? shared : "");
|
||||
if (set) set.disabled = !hasPin && securityState.pinStorable === false;
|
||||
// A PIN stored by Aegis before 0.31 sat on disk without OS protection.
|
||||
// Copies of the profile made back then still hold the master password
|
||||
|
|
@ -4890,7 +4903,9 @@ $("gsPinRemove") && $("gsPinRemove").addEventListener("click", async () => {
|
|||
title: "Remove the quick-access PIN?",
|
||||
danger: true,
|
||||
confirmLabel: "Remove PIN",
|
||||
body: "You'll have to type the master password on every unlock again.",
|
||||
body: securityState.pinUnified
|
||||
? "This removes the Theseus PIN everywhere — Aegis, Settings › Passwords and Pithos. You'll have to type the master password on every unlock again."
|
||||
: "You'll have to type the master password on every unlock again.",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue