From 9d6d8c3cc5b9b90c70706c65f96eb8e50ae62c4e Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 04:15:15 +0200 Subject: [PATCH] =?UTF-8?q?Theseus:=20one=20PIN=20=E2=80=94=20the=20vault?= =?UTF-8?q?=20PIN,=20offered=20to=20Aegis=20through=20api.vault.pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- addons-host.js | 29 +++++++++++++++++- lib/vault-pin.cjs | 77 ++++++++++++++++++++++++++++------------------- main.js | 38 +++++++++++++++++++++-- settings.html | 8 ++--- unlock.html | 40 +++++++++++++----------- 5 files changed, 136 insertions(+), 56 deletions(-) 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.