diff --git a/addons-host.js b/addons-host.js index 300d5631..7d7f8788 100644 --- a/addons-host.js +++ b/addons-host.js @@ -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" } diff --git a/lib/vault-pin.cjs b/lib/vault-pin.cjs index 068d7832..918a590b 100644 --- a/lib/vault-pin.cjs +++ b/lib/vault-pin.cjs @@ -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: , fails, requireMaster } +// File: { v: 1, sealed: true, data: , fails, last } // blob = { salt, iv, ct, iters, hw? } (all b64 except iters) // hw = { kind: "tpm", key: , wrapped: } @@ -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 }; diff --git a/main.js b/main.js index a06a4d52..837eac38 100644 --- a/main.js +++ b/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 { diff --git a/settings.html b/settings.html index 7949beec..83799681 100644 --- a/settings.html +++ b/settings.html @@ -587,7 +587,7 @@ @@ -600,7 +600,7 @@

Quick-unlock PIN

PIN
-
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.
+
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.