Theseus: one PIN — the vault PIN, offered to Aegis through api.vault.pin
Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.
The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
This commit is contained in:
parent
b292408cea
commit
9d6d8c3cc5
5 changed files with 136 additions and 56 deletions
|
|
@ -313,6 +313,9 @@ class AddonHost {
|
|||
// vaultRequestUnlock: Theseus shows its own PIN / master-password prompt
|
||||
// and resolves { ok } — the add-on never sees what the user typed.
|
||||
this._vaultRequestUnlock = typeof arguments[0].vaultRequestUnlock === "function" ? arguments[0].vaultRequestUnlock : null;
|
||||
// vaultPin: the one PIN (lib/vault-pin.cjs), for built-in add-ons that
|
||||
// draw their own PIN pad. {status, unlock(pin), set(pin, pw), clear}.
|
||||
this._vaultPin = arguments[0].vaultPin && typeof arguments[0].vaultPin.unlock === "function" ? arguments[0].vaultPin : null;
|
||||
// isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus,
|
||||
// and which legacy ids those absorb. Gate `absorbs` in vault.derive.
|
||||
this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null;
|
||||
|
|
@ -406,6 +409,29 @@ class AddonHost {
|
|||
};
|
||||
}
|
||||
|
||||
// api.vault.pin namespace: the Theseus vault PIN, checked in main with one
|
||||
// strike counter, for built-in add-ons that show their own PIN pad. unlock
|
||||
// opens the vault and answers { ok } or why not; the master password the
|
||||
// PIN wraps stays in main. Built-in only: a PIN check is a guessing oracle
|
||||
// and set() takes the master password.
|
||||
_makePinApi(manifest) {
|
||||
const gate = (what) => {
|
||||
if (!manifest.capabilities.includes("vault-derive")) {
|
||||
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
|
||||
}
|
||||
if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) {
|
||||
throw new Error(`vault.pin.${what} is reserved for built-in add-ons — use vault.requestUnlock()`);
|
||||
}
|
||||
if (!this._vaultPin) throw new Error("vault.pin unavailable (host not wired)");
|
||||
};
|
||||
return {
|
||||
status: async () => { gate("status"); return this._vaultPin.status(manifest.id); },
|
||||
unlock: async (pin) => { gate("unlock"); return this._vaultPin.unlock(String(pin || ""), manifest.id); },
|
||||
set: async (pin, pw) => { gate("set"); return this._vaultPin.set(String(pin || ""), String(pw || ""), manifest.id); },
|
||||
clear: async () => { gate("clear"); return this._vaultPin.clear(manifest.id); },
|
||||
};
|
||||
}
|
||||
|
||||
// api.vault.imports namespace factory. Gated by the "vault-derive" cap
|
||||
// because the two surfaces sit at the same trust tier (design §3.2). If
|
||||
// main didn't wire the vaultImports shim, calls throw a clear error.
|
||||
|
|
@ -631,7 +657,7 @@ class AddonHost {
|
|||
return {
|
||||
// What this host can do beyond the documented surface, so an add-on
|
||||
// can tell "the user switched it off" from "this Theseus is too old".
|
||||
features: Object.freeze({ pageInjectPolicy: true }),
|
||||
features: Object.freeze({ pageInjectPolicy: true, vaultPin: !!this._vaultPin }),
|
||||
// Metadata the add-on may want to reflect on
|
||||
id: manifest.id,
|
||||
folder,
|
||||
|
|
@ -877,6 +903,7 @@ class AddonHost {
|
|||
},
|
||||
imports: this._makeImportsApi(manifest),
|
||||
lifecycle: this._makeLifecycleApi(manifest),
|
||||
pin: this._makePinApi(manifest),
|
||||
// Ask Theseus to unlock the vault: it prompts for the PIN (or the
|
||||
// master password) in its own overlay. Resolves { ok: true } when
|
||||
// the vault is open, { ok: false, reason: "no-vault" | "cancelled" }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// Quick-unlock PIN for the password vault.
|
||||
// Quick-unlock PIN for the password vault — the one PIN in Theseus. Settings,
|
||||
// the unlock prompt, Pithos (requestUnlock) and Aegis's PIN pads
|
||||
// (api.vault.pin) all check it here, against one strike counter.
|
||||
//
|
||||
// The PIN is an alias for the master password, never a replacement: it
|
||||
// encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the
|
||||
|
|
@ -18,13 +20,15 @@
|
|||
// unsealed record from an older build is deleted. On Linux the basic_text
|
||||
// backend (a constant key compiled into Chromium) counts as no keystore.
|
||||
//
|
||||
// Three wrong PINs in a row switch to "master password required". That flag
|
||||
// lives in the same file, so restarting Theseus does not reset it; only a
|
||||
// successful master-password unlock does. Anyone who can write the file can
|
||||
// reset it, which is why the TPM lockout, not this counter, is the limit that
|
||||
// matters against an attacker on the machine.
|
||||
// Five wrong PINs lock the PIN for 15 minutes, and every further wrong PIN
|
||||
// locks it again (the policy Aegis's PIN screens have always described). The
|
||||
// master password works throughout, and a correct PIN or a master-password
|
||||
// unlock clears the count. The counter lives in this file, so a restart does
|
||||
// not reset it — but anyone who can write the file can, which is why the
|
||||
// TPM lockout, not this counter, is the limit that matters against an
|
||||
// attacker on the machine.
|
||||
//
|
||||
// File: { v: 1, sealed: true, data: <b64 safeStorage blob>, fails, requireMaster }
|
||||
// File: { v: 1, sealed: true, data: <b64 safeStorage blob>, fails, last }
|
||||
// blob = { salt, iv, ct, iters, hw? } (all b64 except iters)
|
||||
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
|
||||
|
||||
|
|
@ -34,11 +38,12 @@ const fs = require("node:fs");
|
|||
const crypto = require("node:crypto");
|
||||
const tpmPin = require("./tpm-pin.cjs");
|
||||
|
||||
const MAX_FAILS = 3;
|
||||
const MAX_FAILS = 5;
|
||||
const LOCKOUT_MS = 15 * 60 * 1000;
|
||||
const ITERATIONS = 600_000;
|
||||
const PIN_RE = /^\d{6}$/;
|
||||
|
||||
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
|
||||
const sealAvailable = () => {
|
||||
try {
|
||||
if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false;
|
||||
|
|
@ -60,6 +65,9 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
try { fs.unlinkSync(file); } catch {}
|
||||
return null;
|
||||
}
|
||||
// Records from the 3-strike build: "master password required" becomes
|
||||
// one lockout period.
|
||||
if (rec && rec.requireMaster) { rec.fails = Math.max(rec.fails || 0, MAX_FAILS); rec.last = rec.last || now(); delete rec.requireMaster; }
|
||||
return rec;
|
||||
}
|
||||
function write(rec) {
|
||||
|
|
@ -67,6 +75,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
const lockedMsOf = (rec) => (rec && (rec.fails || 0) >= MAX_FAILS ? Math.max(0, LOCKOUT_MS - (now() - (rec.last || 0))) : 0);
|
||||
|
||||
function blobOf(rec) {
|
||||
if (!rec) return null;
|
||||
|
|
@ -78,8 +87,9 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
const keyFor = (pin, salt, iters) => new Promise((resolve, reject) =>
|
||||
crypto.pbkdf2(String(pin), salt, iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
|
||||
|
||||
return {
|
||||
const self = {
|
||||
MAX_FAILS,
|
||||
LOCKOUT_MS,
|
||||
|
||||
status() {
|
||||
const rec = read();
|
||||
|
|
@ -87,7 +97,8 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
return {
|
||||
pinSet: !!rec,
|
||||
fails: rec ? rec.fails || 0 : 0,
|
||||
requireMaster: !!(rec && rec.requireMaster),
|
||||
last: rec ? rec.last || 0 : 0,
|
||||
lockedMs: lockedMsOf(rec),
|
||||
sealed: !!(rec && rec.sealed),
|
||||
hardware: b ? (b.hw ? "tpm" : "none") : null,
|
||||
storable: sealAvailable(),
|
||||
|
|
@ -118,7 +129,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
sealed: true,
|
||||
data: safeStorage.encryptString(JSON.stringify(blob)).toString("base64"),
|
||||
fails: 0,
|
||||
requireMaster: false,
|
||||
last: 0,
|
||||
});
|
||||
if (old?.hw?.key && old.hw.key !== hw?.keyName) tpm.remove(old.hw.key).catch(() => {});
|
||||
return { hardware: hw ? "tpm" : "none" };
|
||||
|
|
@ -131,14 +142,15 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
},
|
||||
|
||||
// Returns the master password, or throws:
|
||||
// { code: "no-pin" | "master-required" | "wrong-pin" | "tpm-locked", remaining }
|
||||
// { code: "no-pin" | "locked" | "wrong-pin" | "tpm-locked" | "pin-gone", remaining, lockedMs }
|
||||
async open(pin) {
|
||||
const rec = read();
|
||||
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin" });
|
||||
if (rec.requireMaster) throw Object.assign(new Error("enter the master password"), { code: "master-required", remaining: 0 });
|
||||
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
|
||||
const locked = lockedMsOf(rec);
|
||||
if (locked > 0) throw Object.assign(new Error("too many wrong PINs"), { code: "locked", remaining: 0, lockedMs: locked });
|
||||
// Count the guess before trying it, so a crash mid-check still costs one.
|
||||
const before = rec.fails || 0;
|
||||
rec.fails = before + 1;
|
||||
rec.fails = (rec.fails || 0) + 1;
|
||||
rec.last = now();
|
||||
write(rec);
|
||||
let masterPassword = null;
|
||||
if (PIN_RE.test(String(pin || ""))) {
|
||||
|
|
@ -149,13 +161,15 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
const r = await tpm.open(b.hw.key, b.hw.wrapped, pin);
|
||||
if (r.ok) secret = r.secret;
|
||||
else if (r.code === "locked" || r.code === "error") {
|
||||
rec.fails = before; write(rec); // not a verdict on the PIN
|
||||
// Not a verdict on the PIN: give this one attempt back.
|
||||
const cur = read();
|
||||
if (cur) { cur.fails = Math.max(0, (cur.fails || 0) - 1); write(cur); }
|
||||
throw Object.assign(new Error(r.code === "locked"
|
||||
? "The security chip is refusing PINs for a few minutes after too many wrong ones. Enter the master password, or wait."
|
||||
: "The security chip did not answer. Enter the master password."), { code: "tpm-locked", remaining: MAX_FAILS - before });
|
||||
? "The security chip is refusing PINs for a few minutes after too many wrong ones. Use the master password, or wait."
|
||||
: "The security chip did not answer. Use the master password."), { code: "tpm-locked", remaining: MAX_FAILS - (rec.fails - 1), lockedMs: 0 });
|
||||
} else if (r.code === "missing") {
|
||||
this.clear();
|
||||
throw Object.assign(new Error("This PIN was tied to a security chip that no longer has its key. Enter the master password, then set the PIN again."), { code: "master-required", remaining: 0 });
|
||||
self.clear();
|
||||
throw Object.assign(new Error("This PIN was tied to a security chip that no longer has its key. Enter the master password, then set the PIN again."), { code: "pin-gone", remaining: 0, lockedMs: 0 });
|
||||
}
|
||||
}
|
||||
if (!b.hw || secret) {
|
||||
|
|
@ -167,27 +181,28 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
|||
masterPassword = Buffer.concat([decipher.update(ct.subarray(0, ct.length - 16)), decipher.final()]).toString("utf8");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && (e.code === "tpm-locked" || e.code === "master-required")) throw e;
|
||||
if (e && (e.code === "tpm-locked" || e.code === "pin-gone")) throw e;
|
||||
masterPassword = null;
|
||||
}
|
||||
}
|
||||
if (masterPassword == null) {
|
||||
if (rec.fails >= MAX_FAILS) rec.requireMaster = true;
|
||||
write(rec);
|
||||
const remaining = Math.max(0, MAX_FAILS - rec.fails);
|
||||
throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs, enter the master password"),
|
||||
{ code: remaining ? "wrong-pin" : "master-required", remaining });
|
||||
const cur = read() || rec;
|
||||
const remaining = Math.max(0, MAX_FAILS - (cur.fails || 0));
|
||||
throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs"),
|
||||
{ code: remaining ? "wrong-pin" : "locked", remaining, lockedMs: lockedMsOf(cur) });
|
||||
}
|
||||
rec.fails = 0; write(rec);
|
||||
const cur = read() || rec;
|
||||
cur.fails = 0; cur.last = 0; write(cur);
|
||||
return masterPassword;
|
||||
},
|
||||
|
||||
// A successful master-password unlock clears the strikes.
|
||||
resetFails() {
|
||||
const rec = read();
|
||||
if (rec && (rec.fails || rec.requireMaster)) { rec.fails = 0; rec.requireMaster = false; write(rec); }
|
||||
if (rec && (rec.fails || rec.last)) { rec.fails = 0; rec.last = 0; write(rec); }
|
||||
},
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
module.exports = { createVaultPin, MAX_FAILS };
|
||||
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS };
|
||||
|
|
|
|||
38
main.js
38
main.js
|
|
@ -2566,6 +2566,36 @@ function initAddons() {
|
|||
},
|
||||
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
|
||||
vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }),
|
||||
// The one PIN, for built-in add-ons that draw their own PIN pad (Aegis).
|
||||
// unlock() opens the vault here and answers only { ok } or why not — the
|
||||
// master password the PIN wraps never leaves main.
|
||||
vaultPin: {
|
||||
status: () => ({ ...vaultPin().status(), maxFails: vaultPin().MAX_FAILS }),
|
||||
unlock: async (pin, addonId) => {
|
||||
let pw;
|
||||
try { pw = await vaultPin().open(String(pin || "")); }
|
||||
catch (err) {
|
||||
return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0,
|
||||
error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message : undefined };
|
||||
}
|
||||
try { await unlockVaultWithMaster(pw); }
|
||||
catch {
|
||||
// The PIN opened, but its password no longer opens the vault.
|
||||
vaultPin().clear();
|
||||
return { ok: false, code: "stale", remaining: 0, lockedMs: 0, error: "Your PIN is out of date. Enter the master password, then set a new PIN." };
|
||||
}
|
||||
console.log(`[addons] [${addonId}] vault PIN accepted`);
|
||||
return { ok: true };
|
||||
},
|
||||
set: async (pin, masterPassword, addonId) => {
|
||||
try { await unlockVaultWithMaster(String(masterPassword || "")); }
|
||||
catch { await new Promise((r) => setTimeout(r, 600)); throw new Error("wrong master password"); }
|
||||
const r = await vaultPin().set(String(pin || ""), String(masterPassword));
|
||||
console.log(`[addons] [${addonId}] vault PIN set`);
|
||||
return { ok: true, ...r };
|
||||
},
|
||||
clear: (addonId) => { vaultPin().clear(); console.log(`[addons] [${addonId}] vault PIN cleared`); return true; },
|
||||
},
|
||||
isFirstPartyId: (id) => firstPartyAddonIds().bundled.has(String(id)),
|
||||
isReservedId: (id) => firstPartyAddonIds().absorbed.has(String(id)),
|
||||
emitToPanel: (addonId, msg, payload) => {
|
||||
|
|
@ -6830,7 +6860,7 @@ function pumpUnlock() {
|
|||
unlockCurrent = next;
|
||||
const st = vaultPin().status();
|
||||
overlayReady(unlockPop).then(() => {
|
||||
unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, requireMaster: st.requireMaster });
|
||||
unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS });
|
||||
try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
|
||||
unlockPop.setVisible(true);
|
||||
unlockPop.webContents.focus();
|
||||
|
|
@ -6855,8 +6885,10 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
|
|||
if (mode === "pin") {
|
||||
try { masterPassword = await vaultPin().open(value); }
|
||||
catch (err) {
|
||||
if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} ${err.remaining === 1 ? "try" : "tries"} left.` };
|
||||
return { ok: false, mode: "password", error: err.code === "master-required" && !/security chip/.test(err.message) ? "Too many wrong PINs. Enter the master password." : err.message };
|
||||
// Same words as Aegis's PIN pads: one PIN, one policy, one wording.
|
||||
if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} attempt${err.remaining === 1 ? "" : "s"} left before a 15 min lockout.` };
|
||||
if (err.code === "locked") return { ok: false, mode: "password", lockedMs: err.lockedMs, error: `Too many failed attempts. Try again in ${Math.max(1, Math.ceil((err.lockedMs || 0) / 60000))} min or use the master password.` };
|
||||
return { ok: false, mode: "password", error: err.message };
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@
|
|||
</div>
|
||||
<div id="pwUnlockErr" class="pmuted" style="color:#f6768a;font-size:12.5px;margin-top:4px" hidden></div>
|
||||
<div class="row" id="pwPinUnlockRow" hidden>
|
||||
<div class="txt"><div class="t">Or use your PIN</div><div class="d">Three wrong PINs and Theseus asks for the master password instead.</div></div>
|
||||
<div class="txt"><div class="t">Or use your PIN</div><div class="d">Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
|
||||
<div class="ctl"><button id="pwPinUnlockBtn" class="btn" type="button">Unlock with PIN</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -600,7 +600,7 @@
|
|||
<h2 class="sub">Quick-unlock PIN</h2>
|
||||
<div class="row">
|
||||
<div class="txt"><div class="t">PIN</div>
|
||||
<div class="d" id="pinDesc">A 6-digit PIN that unlocks the vault instead of the master password, here and in extensions such as Pithos. Three wrong PINs and the master password is required.</div></div>
|
||||
<div class="d" id="pinDesc">A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
|
||||
<div class="ctl"><button id="pinSetBtn" class="btn" type="button">Set a PIN</button><button id="pinClearBtn" class="btn" type="button" hidden>Remove</button></div>
|
||||
</div>
|
||||
<div id="pinForm" class="row" style="flex-direction:column;align-items:stretch;gap:8px" hidden>
|
||||
|
|
@ -2062,8 +2062,8 @@
|
|||
document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
|
||||
document.getElementById("pinClearBtn").hidden = !set;
|
||||
const desc = document.getElementById("pinDesc");
|
||||
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here and in extensions such as Pithos. Three wrong PINs and the master password is required.";
|
||||
desc.textContent = set && pin.requireMaster ? base + " The PIN is paused after wrong tries; it works again after the next master-password unlock."
|
||||
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.";
|
||||
desc.textContent = set && pin.lockedMs > 0 ? base + ` Locked after wrong tries — it works again in ${Math.max(1, Math.ceil(pin.lockedMs / 60000))} min, or at once after a master-password unlock.`
|
||||
: set && pin.hardware === "tpm" ? base + " 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."
|
||||
: set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password."
|
||||
: pin && pin.storable === false ? base + " This system has no protected keystore, so a PIN cannot be stored safely here."
|
||||
|
|
|
|||
40
unlock.html
40
unlock.html
|
|
@ -24,14 +24,19 @@
|
|||
.who .addon { color: var(--mut); }
|
||||
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
|
||||
.reason { color: var(--mut); margin: 0 0 14px; }
|
||||
.dots { display: flex; gap: 10px; justify-content: center; margin: 6px 0 14px; }
|
||||
.dots i { width: 13px; height: 13px; border-radius: 50%; border: 2px solid var(--mut); }
|
||||
.dots i.on { background: var(--acid); border-color: var(--acid); }
|
||||
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
|
||||
.pad button { padding: 11px 0; font: 600 17px system-ui, sans-serif; border-radius: 9px; border: 1px solid var(--line);
|
||||
background: var(--surface2); color: var(--ink); cursor: pointer; }
|
||||
.pad button:hover { border-color: rgb(from var(--acid) r g b / .4); }
|
||||
.pad button.muted { font-size: 13px; color: var(--mut); }
|
||||
/* The same PIN pad as Aegis (panel.html .pinpad): one PIN, one screen. */
|
||||
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 12px; }
|
||||
.pinpad .pindots { display: flex; gap: 12px; }
|
||||
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim);
|
||||
background: transparent; transition: background .12s, border-color .12s; }
|
||||
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid);
|
||||
box-shadow: 0 0 6px rgb(from var(--acid) r g b / .5); }
|
||||
.pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 62px); gap: 8px; }
|
||||
.pinpad .pinkeys button { height: 46px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; }
|
||||
.pinpad .pinkeys button:hover { border-color: var(--acid); color: var(--acid); }
|
||||
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
|
||||
.pinpad .pinerr { color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; }
|
||||
input[type=password] { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; }
|
||||
input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
|
||||
|
|
@ -89,6 +94,7 @@
|
|||
function press(d) {
|
||||
if (busy) return;
|
||||
if (d === "back") pin = pin.slice(0, -1);
|
||||
else if (d === "clear") pin = "";
|
||||
else if (pin.length < 6) pin += d;
|
||||
error = "";
|
||||
render();
|
||||
|
|
@ -104,12 +110,12 @@
|
|||
];
|
||||
let bodyEls;
|
||||
if (mode === "pin") {
|
||||
const dots = el("div", { class: "dots" }, ...Array.from({ length: 6 }, (_, i) => el("i", { class: i < pin.length ? "on" : "" })));
|
||||
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "back"];
|
||||
const pad = el("div", { class: "pad" }, ...keys.map((k) => k === ""
|
||||
? el("span")
|
||||
: el("button", { type: "button", class: k === "back" ? "muted" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k }, k === "back" ? "⌫" : k)));
|
||||
bodyEls = [dots, el("div", { class: "err" }, error), pad,
|
||||
const dots = el("div", { class: "pindots" }, ...Array.from({ length: 6 }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" })));
|
||||
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
||||
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
|
||||
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
|
||||
k === "back" ? "⌫" : k === "clear" ? "Clear" : k)));
|
||||
bodyEls = [el("div", { class: "pinpad" }, dots, pad, el("div", { class: "pinerr" }, error)),
|
||||
el("div", { class: "pact" },
|
||||
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"),
|
||||
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
|
||||
|
|
@ -120,7 +126,7 @@
|
|||
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
|
||||
el("div", { class: "err" }, error),
|
||||
el("div", { class: "pact" },
|
||||
req.pinSet && !req.requireMaster && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; render(); } }, "Use PIN") : el("span"),
|
||||
req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; render(); } }, "Use PIN") : el("span"),
|
||||
el("div", { style: "display:flex;gap:6px" },
|
||||
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
||||
bodyEls = [form];
|
||||
|
|
@ -132,9 +138,9 @@
|
|||
|
||||
window.unlock.onShow((r) => {
|
||||
req = r;
|
||||
mode = r.pinSet && !r.requireMaster ? "pin" : "password";
|
||||
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
|
||||
pin = "";
|
||||
error = r.requireMaster && r.pinSet ? "Too many wrong PINs. Enter the master password." : "";
|
||||
error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : "";
|
||||
busy = false;
|
||||
render();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue