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
|
// vaultRequestUnlock: Theseus shows its own PIN / master-password prompt
|
||||||
// and resolves { ok } — the add-on never sees what the user typed.
|
// and resolves { ok } — the add-on never sees what the user typed.
|
||||||
this._vaultRequestUnlock = typeof arguments[0].vaultRequestUnlock === "function" ? arguments[0].vaultRequestUnlock : null;
|
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,
|
// isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus,
|
||||||
// and which legacy ids those absorb. Gate `absorbs` in vault.derive.
|
// and which legacy ids those absorb. Gate `absorbs` in vault.derive.
|
||||||
this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null;
|
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
|
// 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
|
// 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.
|
// main didn't wire the vaultImports shim, calls throw a clear error.
|
||||||
|
|
@ -631,7 +657,7 @@ class AddonHost {
|
||||||
return {
|
return {
|
||||||
// What this host can do beyond the documented surface, so an add-on
|
// 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".
|
// 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
|
// Metadata the add-on may want to reflect on
|
||||||
id: manifest.id,
|
id: manifest.id,
|
||||||
folder,
|
folder,
|
||||||
|
|
@ -877,6 +903,7 @@ class AddonHost {
|
||||||
},
|
},
|
||||||
imports: this._makeImportsApi(manifest),
|
imports: this._makeImportsApi(manifest),
|
||||||
lifecycle: this._makeLifecycleApi(manifest),
|
lifecycle: this._makeLifecycleApi(manifest),
|
||||||
|
pin: this._makePinApi(manifest),
|
||||||
// Ask Theseus to unlock the vault: it prompts for the PIN (or the
|
// Ask Theseus to unlock the vault: it prompts for the PIN (or the
|
||||||
// master password) in its own overlay. Resolves { ok: true } when
|
// master password) in its own overlay. Resolves { ok: true } when
|
||||||
// the vault is open, { ok: false, reason: "no-vault" | "cancelled" }
|
// 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
|
// The PIN is an alias for the master password, never a replacement: it
|
||||||
// encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the
|
// 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
|
// unsealed record from an older build is deleted. On Linux the basic_text
|
||||||
// backend (a constant key compiled into Chromium) counts as no keystore.
|
// backend (a constant key compiled into Chromium) counts as no keystore.
|
||||||
//
|
//
|
||||||
// Three wrong PINs in a row switch to "master password required". That flag
|
// Five wrong PINs lock the PIN for 15 minutes, and every further wrong PIN
|
||||||
// lives in the same file, so restarting Theseus does not reset it; only a
|
// locks it again (the policy Aegis's PIN screens have always described). The
|
||||||
// successful master-password unlock does. Anyone who can write the file can
|
// master password works throughout, and a correct PIN or a master-password
|
||||||
// reset it, which is why the TPM lockout, not this counter, is the limit that
|
// unlock clears the count. The counter lives in this file, so a restart does
|
||||||
// matters against an attacker on the machine.
|
// 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)
|
// blob = { salt, iv, ct, iters, hw? } (all b64 except iters)
|
||||||
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
|
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
|
||||||
|
|
||||||
|
|
@ -34,11 +38,12 @@ const fs = require("node:fs");
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
const tpmPin = require("./tpm-pin.cjs");
|
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 ITERATIONS = 600_000;
|
||||||
const PIN_RE = /^\d{6}$/;
|
const PIN_RE = /^\d{6}$/;
|
||||||
|
|
||||||
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
|
||||||
const sealAvailable = () => {
|
const sealAvailable = () => {
|
||||||
try {
|
try {
|
||||||
if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false;
|
if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false;
|
||||||
|
|
@ -60,6 +65,9 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||||
try { fs.unlinkSync(file); } catch {}
|
try { fs.unlinkSync(file); } catch {}
|
||||||
return null;
|
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;
|
return rec;
|
||||||
}
|
}
|
||||||
function write(rec) {
|
function write(rec) {
|
||||||
|
|
@ -67,6 +75,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||||
fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 });
|
fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 });
|
||||||
fs.renameSync(tmp, file);
|
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) {
|
function blobOf(rec) {
|
||||||
if (!rec) return null;
|
if (!rec) return null;
|
||||||
|
|
@ -78,8 +87,9 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||||
const keyFor = (pin, salt, iters) => new Promise((resolve, reject) =>
|
const keyFor = (pin, salt, iters) => new Promise((resolve, reject) =>
|
||||||
crypto.pbkdf2(String(pin), salt, iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
|
crypto.pbkdf2(String(pin), salt, iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
|
||||||
|
|
||||||
return {
|
const self = {
|
||||||
MAX_FAILS,
|
MAX_FAILS,
|
||||||
|
LOCKOUT_MS,
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
const rec = read();
|
const rec = read();
|
||||||
|
|
@ -87,7 +97,8 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||||
return {
|
return {
|
||||||
pinSet: !!rec,
|
pinSet: !!rec,
|
||||||
fails: rec ? rec.fails || 0 : 0,
|
fails: rec ? rec.fails || 0 : 0,
|
||||||
requireMaster: !!(rec && rec.requireMaster),
|
last: rec ? rec.last || 0 : 0,
|
||||||
|
lockedMs: lockedMsOf(rec),
|
||||||
sealed: !!(rec && rec.sealed),
|
sealed: !!(rec && rec.sealed),
|
||||||
hardware: b ? (b.hw ? "tpm" : "none") : null,
|
hardware: b ? (b.hw ? "tpm" : "none") : null,
|
||||||
storable: sealAvailable(),
|
storable: sealAvailable(),
|
||||||
|
|
@ -118,7 +129,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||||
sealed: true,
|
sealed: true,
|
||||||
data: safeStorage.encryptString(JSON.stringify(blob)).toString("base64"),
|
data: safeStorage.encryptString(JSON.stringify(blob)).toString("base64"),
|
||||||
fails: 0,
|
fails: 0,
|
||||||
requireMaster: false,
|
last: 0,
|
||||||
});
|
});
|
||||||
if (old?.hw?.key && old.hw.key !== hw?.keyName) tpm.remove(old.hw.key).catch(() => {});
|
if (old?.hw?.key && old.hw.key !== hw?.keyName) tpm.remove(old.hw.key).catch(() => {});
|
||||||
return { hardware: hw ? "tpm" : "none" };
|
return { hardware: hw ? "tpm" : "none" };
|
||||||
|
|
@ -131,14 +142,15 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {} }) {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Returns the master password, or throws:
|
// 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) {
|
async open(pin) {
|
||||||
const rec = read();
|
const rec = read();
|
||||||
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin" });
|
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
|
||||||
if (rec.requireMaster) throw Object.assign(new Error("enter the master password"), { code: "master-required", remaining: 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.
|
// Count the guess before trying it, so a crash mid-check still costs one.
|
||||||
const before = rec.fails || 0;
|
rec.fails = (rec.fails || 0) + 1;
|
||||||
rec.fails = before + 1;
|
rec.last = now();
|
||||||
write(rec);
|
write(rec);
|
||||||
let masterPassword = null;
|
let masterPassword = null;
|
||||||
if (PIN_RE.test(String(pin || ""))) {
|
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);
|
const r = await tpm.open(b.hw.key, b.hw.wrapped, pin);
|
||||||
if (r.ok) secret = r.secret;
|
if (r.ok) secret = r.secret;
|
||||||
else if (r.code === "locked" || r.code === "error") {
|
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"
|
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 is refusing PINs for a few minutes after too many wrong ones. Use 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 did not answer. Use the master password."), { code: "tpm-locked", remaining: MAX_FAILS - (rec.fails - 1), lockedMs: 0 });
|
||||||
} else if (r.code === "missing") {
|
} else if (r.code === "missing") {
|
||||||
this.clear();
|
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: "master-required", remaining: 0 });
|
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) {
|
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");
|
masterPassword = Buffer.concat([decipher.update(ct.subarray(0, ct.length - 16)), decipher.final()]).toString("utf8");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
masterPassword = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (masterPassword == null) {
|
if (masterPassword == null) {
|
||||||
if (rec.fails >= MAX_FAILS) rec.requireMaster = true;
|
const cur = read() || rec;
|
||||||
write(rec);
|
const remaining = Math.max(0, MAX_FAILS - (cur.fails || 0));
|
||||||
const remaining = Math.max(0, MAX_FAILS - rec.fails);
|
throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs"),
|
||||||
throw Object.assign(new Error(remaining ? "wrong PIN" : "too many wrong PINs, enter the master password"),
|
{ code: remaining ? "wrong-pin" : "locked", remaining, lockedMs: lockedMsOf(cur) });
|
||||||
{ code: remaining ? "wrong-pin" : "master-required", remaining });
|
|
||||||
}
|
}
|
||||||
rec.fails = 0; write(rec);
|
const cur = read() || rec;
|
||||||
|
cur.fails = 0; cur.last = 0; write(cur);
|
||||||
return masterPassword;
|
return masterPassword;
|
||||||
},
|
},
|
||||||
|
|
||||||
// A successful master-password unlock clears the strikes.
|
// A successful master-password unlock clears the strikes.
|
||||||
resetFails() {
|
resetFails() {
|
||||||
const rec = read();
|
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),
|
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
|
||||||
vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, 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)),
|
isFirstPartyId: (id) => firstPartyAddonIds().bundled.has(String(id)),
|
||||||
isReservedId: (id) => firstPartyAddonIds().absorbed.has(String(id)),
|
isReservedId: (id) => firstPartyAddonIds().absorbed.has(String(id)),
|
||||||
emitToPanel: (addonId, msg, payload) => {
|
emitToPanel: (addonId, msg, payload) => {
|
||||||
|
|
@ -6830,7 +6860,7 @@ function pumpUnlock() {
|
||||||
unlockCurrent = next;
|
unlockCurrent = next;
|
||||||
const st = vaultPin().status();
|
const st = vaultPin().status();
|
||||||
overlayReady(unlockPop).then(() => {
|
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
|
try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
|
||||||
unlockPop.setVisible(true);
|
unlockPop.setVisible(true);
|
||||||
unlockPop.webContents.focus();
|
unlockPop.webContents.focus();
|
||||||
|
|
@ -6855,8 +6885,10 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
|
||||||
if (mode === "pin") {
|
if (mode === "pin") {
|
||||||
try { masterPassword = await vaultPin().open(value); }
|
try { masterPassword = await vaultPin().open(value); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} ${err.remaining === 1 ? "try" : "tries"} left.` };
|
// Same words as Aegis's PIN pads: one PIN, one policy, one wording.
|
||||||
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 };
|
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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -587,7 +587,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="pwUnlockErr" class="pmuted" style="color:#f6768a;font-size:12.5px;margin-top:4px" hidden></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="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 class="ctl"><button id="pwPinUnlockBtn" class="btn" type="button">Unlock with PIN</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -600,7 +600,7 @@
|
||||||
<h2 class="sub">Quick-unlock PIN</h2>
|
<h2 class="sub">Quick-unlock PIN</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="txt"><div class="t">PIN</div>
|
<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 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>
|
||||||
<div id="pinForm" class="row" style="flex-direction:column;align-items:stretch;gap:8px" hidden>
|
<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("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
|
||||||
document.getElementById("pinClearBtn").hidden = !set;
|
document.getElementById("pinClearBtn").hidden = !set;
|
||||||
const desc = document.getElementById("pinDesc");
|
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.";
|
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.requireMaster ? base + " The PIN is paused after wrong tries; it works again after the next master-password unlock."
|
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 && 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."
|
: 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."
|
: 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); }
|
.who .addon { color: var(--mut); }
|
||||||
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
|
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
|
||||||
.reason { color: var(--mut); margin: 0 0 14px; }
|
.reason { color: var(--mut); margin: 0 0 14px; }
|
||||||
.dots { display: flex; gap: 10px; justify-content: center; margin: 6px 0 14px; }
|
/* The same PIN pad as Aegis (panel.html .pinpad): one PIN, one screen. */
|
||||||
.dots i { width: 13px; height: 13px; border-radius: 50%; border: 2px solid var(--mut); }
|
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 12px; }
|
||||||
.dots i.on { background: var(--acid); border-color: var(--acid); }
|
.pinpad .pindots { display: flex; gap: 12px; }
|
||||||
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
|
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim);
|
||||||
.pad button { padding: 11px 0; font: 600 17px system-ui, sans-serif; border-radius: 9px; border: 1px solid var(--line);
|
background: transparent; transition: background .12s, border-color .12s; }
|
||||||
background: var(--surface2); color: var(--ink); cursor: pointer; }
|
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid);
|
||||||
.pad button:hover { border-color: rgb(from var(--acid) r g b / .4); }
|
box-shadow: 0 0 6px rgb(from var(--acid) r g b / .5); }
|
||||||
.pad button.muted { font-size: 13px; color: var(--mut); }
|
.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);
|
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; }
|
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; }
|
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) {
|
function press(d) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
if (d === "back") pin = pin.slice(0, -1);
|
if (d === "back") pin = pin.slice(0, -1);
|
||||||
|
else if (d === "clear") pin = "";
|
||||||
else if (pin.length < 6) pin += d;
|
else if (pin.length < 6) pin += d;
|
||||||
error = "";
|
error = "";
|
||||||
render();
|
render();
|
||||||
|
|
@ -104,12 +110,12 @@
|
||||||
];
|
];
|
||||||
let bodyEls;
|
let bodyEls;
|
||||||
if (mode === "pin") {
|
if (mode === "pin") {
|
||||||
const dots = el("div", { class: "dots" }, ...Array.from({ length: 6 }, (_, i) => el("i", { class: i < pin.length ? "on" : "" })));
|
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", "", "0", "back"];
|
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
||||||
const pad = el("div", { class: "pad" }, ...keys.map((k) => k === ""
|
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
|
||||||
? el("span")
|
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
|
||||||
: el("button", { type: "button", class: k === "back" ? "muted" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k }, k === "back" ? "⌫" : k)));
|
k === "back" ? "⌫" : k === "clear" ? "Clear" : k)));
|
||||||
bodyEls = [dots, el("div", { class: "err" }, error), pad,
|
bodyEls = [el("div", { class: "pinpad" }, dots, pad, el("div", { class: "pinerr" }, error)),
|
||||||
el("div", { class: "pact" },
|
el("div", { class: "pact" },
|
||||||
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"),
|
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"),
|
||||||
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
|
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,
|
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
|
||||||
el("div", { class: "err" }, error),
|
el("div", { class: "err" }, error),
|
||||||
el("div", { class: "pact" },
|
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("div", { style: "display:flex;gap:6px" },
|
||||||
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
||||||
bodyEls = [form];
|
bodyEls = [form];
|
||||||
|
|
@ -132,9 +138,9 @@
|
||||||
|
|
||||||
window.unlock.onShow((r) => {
|
window.unlock.onShow((r) => {
|
||||||
req = r;
|
req = r;
|
||||||
mode = r.pinSet && !r.requireMaster ? "pin" : "password";
|
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
|
||||||
pin = "";
|
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;
|
busy = false;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue