diff --git a/addons-host.js b/addons-host.js index 284cbdbd..ff29a5bc 100644 --- a/addons-host.js +++ b/addons-host.js @@ -16,7 +16,7 @@ // /extensions-data/.json — per-add-on kv store (api.storage) const fs = require("node:fs"); -const { storeFor } = require("./lib/addon-store.cjs"); +const { storeFor } = require("./lib/addon-store.cjs"); const path = require("node:path"); // Extension points the framework understands. Extending this list means also @@ -237,6 +237,9 @@ class AddonHost { // to Settings > Passwords. Same "vault-derive" capability gate. this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function" ? arguments[0].vaultLifecycle : null; + // 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; this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null; @@ -652,6 +655,17 @@ class AddonHost { }, imports: this._makeImportsApi(manifest), lifecycle: this._makeLifecycleApi(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" } + // otherwise. Already unlocked resolves at once without a prompt. + requestUnlock: async (opts = {}) => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultRequestUnlock) throw new Error("vault.requestUnlock unavailable (host not wired)"); + return this._vaultRequestUnlock({ reason: String(opts.reason || "").slice(0, 200) }, manifest.id); + }, }, // approval-modal: ask the user. Resolves to the chosen action id, or // "cancel" (Escape / mask click / window closed). With `checkbox` set diff --git a/lib/vault-pin.cjs b/lib/vault-pin.cjs new file mode 100644 index 00000000..c6db67df --- /dev/null +++ b/lib/vault-pin.cjs @@ -0,0 +1,122 @@ +// Quick-unlock PIN for the password vault. +// +// The PIN is an alias for the master password, never a replacement: it +// encrypts the master password (PBKDF2-SHA256 -> AES-256-GCM), and the +// result is sealed again with Electron safeStorage (DPAPI on Windows, +// Keychain on macOS, libsecret on Linux) where available, so a copied +// vault-pin.json is useless on another machine or OS account. A 6-digit PIN +// alone would fall to an offline search in minutes; the OS seal is what +// stops that. +// +// 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. +// +// File: { v: 1, sealed: bool, data: | blob, fails, requireMaster } +// blob = { salt, iv, ct, iters } (all b64 except iters) + +"use strict"; + +const fs = require("node:fs"); +const crypto = require("node:crypto"); + +const MAX_FAILS = 3; +const ITERATIONS = 600_000; +const PIN_RE = /^\d{6}$/; + +function createVaultPin({ file, safeStorage }) { + const sealAvailable = () => { + try { return !!(safeStorage && safeStorage.isEncryptionAvailable()); } catch { return false; } + }; + + function read() { + try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } + } + function write(rec) { + const tmp = file + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(rec), { mode: 0o600 }); + fs.renameSync(tmp, file); + } + + function blobOf(rec) { + if (!rec) return null; + if (!rec.sealed) return rec.data; + if (!sealAvailable()) throw new Error("this PIN was sealed by the system keystore, which is not available now"); + return JSON.parse(safeStorage.decryptString(Buffer.from(rec.data, "base64"))); + } + + const keyFor = (pin, salt, iters) => crypto.pbkdf2Sync(String(pin), salt, iters, 32, "sha256"); + + return { + MAX_FAILS, + + status() { + const rec = read(); + return { + pinSet: !!rec, + fails: rec ? rec.fails || 0 : 0, + requireMaster: !!(rec && rec.requireMaster), + sealed: !!(rec && rec.sealed), + }; + }, + + // Caller must have verified masterPassword against the vault first. + set(pin, masterPassword) { + if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits"); + if (!masterPassword) throw new Error("master password required"); + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", keyFor(pin, salt, ITERATIONS), iv); + const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]); + const blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS }; + const sealed = sealAvailable(); + write({ + v: 1, + sealed, + data: sealed ? safeStorage.encryptString(JSON.stringify(blob)).toString("base64") : blob, + fails: 0, + requireMaster: false, + }); + }, + + clear() { + try { fs.unlinkSync(file); } catch {} + }, + + // Returns the master password, or throws: + // { code: "no-pin" | "master-required" | "wrong-pin", remaining } + 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 }); + let masterPassword = null; + if (PIN_RE.test(String(pin || ""))) { + try { + const b = blobOf(rec); + const ct = Buffer.from(b.ct, "base64"); + const decipher = crypto.createDecipheriv("aes-256-gcm", keyFor(pin, Buffer.from(b.salt, "base64"), b.iters), Buffer.from(b.iv, "base64")); + decipher.setAuthTag(ct.subarray(ct.length - 16)); + masterPassword = Buffer.concat([decipher.update(ct.subarray(0, ct.length - 16)), decipher.final()]).toString("utf8"); + } catch { masterPassword = null; } + } + if (masterPassword == null) { + rec.fails = (rec.fails || 0) + 1; + 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 }); + } + if (rec.fails) { rec.fails = 0; write(rec); } + 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); } + }, + }; +} + +module.exports = { createVaultPin, MAX_FAILS }; diff --git a/main.js b/main.js index c3927aa5..f9311fa2 100644 --- a/main.js +++ b/main.js @@ -4,7 +4,7 @@ // h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home // page, and optional Tor onion routing. No system daemon; the app is the trust // boundary. -const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess } = require("electron"); +const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage } = require("electron"); const path = require("path"); const url = require("url"); const http = require("http"); @@ -865,6 +865,7 @@ const SETTINGS_ONLY = new Set([ "password-setup", "password-status", "password-unlock", "password-update", "recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order", "settings-open-panel", "settings-section", "tor-state", + "vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock", ]); const SETTINGS_SHARED = new Set([ "add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state", @@ -2402,6 +2403,7 @@ function initAddons() { catch (ie) { console.error("[imports] unlock via addon failed:", ie?.message); } } importsUnlockPw = masterPassword; + try { vaultPin().resetFails(); } catch {} emitPwAvailability(); console.log(`[addons] [${addonId}] vault.unlock`); return { ok: true }; @@ -2486,6 +2488,7 @@ function initAddons() { }, }, approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), + vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }), emitToPanel: (addonId, msg, payload) => { // Full-tab pages of the same add-on hear it too. addon-tab-preload has // always exposed silentmode.on(), but nothing ever delivered to a tab, @@ -2959,6 +2962,9 @@ function layout() { // Approval overlay sits exactly over the tab area — the page underneath // keeps running; only pointer input is intercepted. if (approvalPop) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + // The vault unlock prompt covers the whole body, sidebar included: the + // add-on asking for it often lives in the sidebar, which may be widened. + if (unlockPop) unlockPop.setBounds({ x: 0, y: chromeH, width, height: bodyH }); if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); positionPopover(); positionEnginePicker(); @@ -4180,6 +4186,12 @@ function createWindow() { styleScrollbars(approvalPop.webContents); deferOverlayLoad(approvalPop, "approval.html"); approvalPop.setVisible(false); + // Vault unlock prompt (PIN or master password), shown per request. + unlockPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "unlock-preload.js") } }); + try { unlockPop.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(unlockPop); + deferOverlayLoad(unlockPop, "unlock.html"); + unlockPop.setVisible(false); // Page dialogs (alert / confirm / prompt) — see the js-dialog block. jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } }); try { jsDialogPop.setBackgroundColor("#00000000"); } catch {} @@ -4222,7 +4234,7 @@ function createWindow() { // pumpApproval (and every later dapp request) until a full restart. if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} } for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} } - addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; jsDialogPop = null; + addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; unlockPop = null; jsDialogPop = null; linkStatusVisible = false; }); layout(); @@ -5704,6 +5716,7 @@ ipcMain.handle("jsdialog-answer", (e, reqId, ok, value) => { return true; }); let approvalPop = null; +let unlockPop = null; // vault unlock prompt (unlock.html), see requestVaultUnlock const approvalQueue = []; let approvalCurrent = null; // { reqId, resolve } let approvalSeq = 0; @@ -6514,6 +6527,111 @@ let importsUnlockPw = null; // held only if we may need to write the fi const vaultOk = () => ({ ok: true }); const vaultErr = (m) => ({ ok: false, err: String(m) }); +// ---- Quick-unlock PIN + the vault unlock prompt ---------------------------- +// The PIN wraps the master password (lib/vault-pin.cjs), so every unlock +// still ends at the master password; three wrong PINs require it outright. +// Extensions ask for an unlock with api.vault.requestUnlock(); Theseus shows +// its own prompt (unlock.html) and the PIN or password never reaches them. +const { createVaultPin } = require("./lib/vault-pin.cjs"); +let vaultPinInst = null; +const vaultPin = () => (vaultPinInst ||= createVaultPin({ file: path.join(app.getPath("userData"), "vault-pin.json"), safeStorage })); + +async function unlockVaultWithMaster(masterPassword) { + if (!fs.existsSync(vaultFile())) throw new Error("no vault"); + const v = await loadVaultLib(); + vaultState = await v.unlockVault(vaultFile(), masterPassword); // throws on a wrong password + importsState = null; + if (fs.existsSync(importsFile())) { + try { importsState = await v.unlockImports(importsFile(), masterPassword); } + catch (ie) { console.error("[imports] unlock failed:", ie?.message); } + } + importsUnlockPw = masterPassword; + try { vaultPin().resetFails(); } catch {} + emitPwAvailability(); + return v; +} + +const unlockQueue = []; +let unlockCurrent = null; +let unlockSeq = 0; +// Resolves { ok: true } once the vault is unlocked, { ok: false, reason } +// when there is no vault or the user cancels. +function requestVaultUnlock({ reason, addonId } = {}) { + if (vaultState) return Promise.resolve({ ok: true, already: true }); + if (!fs.existsSync(vaultFile())) return Promise.resolve({ ok: false, reason: "no-vault" }); + const a = addonId && addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId); + const req = { reqId: ++unlockSeq, addonName: a ? a.manifest.name : "Theseus", reason: String(reason || "").slice(0, 200) }; + return new Promise((resolve) => { unlockQueue.push({ req, resolve }); pumpUnlock(); }); +} +function pumpUnlock() { + if (unlockCurrent || !unlockQueue.length || !unlockPop) return; + const next = unlockQueue.shift(); + if (vaultState) { next.resolve({ ok: true, already: true }); pumpUnlock(); return; } + unlockCurrent = next; + const st = vaultPin().status(); + overlayReady(unlockPop).then(() => { + unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, requireMaster: st.requireMaster }); + try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front + unlockPop.setVisible(true); + unlockPop.webContents.focus(); + }).catch((err) => { + unlockCurrent = null; + next.resolve({ ok: false, reason: "error" }); + console.warn("[vault] unlock prompt failed:", err?.message); + pumpUnlock(); + }); +} +function finishUnlock(result) { + const cur = unlockCurrent; + unlockCurrent = null; + try { unlockPop?.setVisible(false); } catch {} + cur?.resolve(result); + pumpUnlock(); // queued requests resolve at once if the vault is now open +} +ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => { + if (!unlockPop || e.sender !== unlockPop.webContents) return { ok: false, error: "denied" }; + if (!unlockCurrent || unlockCurrent.req.reqId !== reqId) return { ok: false, error: "This prompt has expired." }; + let masterPassword = value; + if (mode === "pin") { + try { masterPassword = 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" ? "Too many wrong PINs. Enter the master password." : err.message }; + } + } + try { + await unlockVaultWithMaster(masterPassword); + } catch { + if (mode === "pin") { + // The PIN opened, but its master password no longer opens the vault + // (the password was changed): the PIN is stale. + vaultPin().clear(); + return { ok: false, mode: "password", error: "Your PIN is out of date. Enter the master password, then set a new PIN in Settings." }; + } + await new Promise((r) => setTimeout(r, 600)); + return { ok: false, mode: "password", error: "Wrong master password." }; + } + finishUnlock({ ok: true }); + return { ok: true }; +}); +ipcMain.handle("unlock-cancel", (e, reqId) => { + if (!unlockPop || e.sender !== unlockPop.webContents) return false; + if (unlockCurrent && unlockCurrent.req.reqId === reqId) finishUnlock({ ok: false, reason: "cancelled" }); + return true; +}); +// Settings › Passwords: set, change or remove the PIN. +ipcMain.handle("vault-pin-status", () => ({ ...vaultPin().status(), vault: fs.existsSync(vaultFile()), unlocked: !!vaultState, maxFails: vaultPin().MAX_FAILS })); +ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => { + try { + // Proves the password before it is wrapped; also unlocks the vault. + await unlockVaultWithMaster(String(masterPassword || "")); + } catch { await new Promise((r) => setTimeout(r, 600)); return vaultErr("wrong master password"); } + try { vaultPin().set(String(pin || ""), String(masterPassword)); return vaultOk(); } + catch (e) { return vaultErr(e.message); } +}); +ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); }); +ipcMain.handle("vault-pin-unlock", () => requestVaultUnlock({ reason: "Open your saved passwords." })); + ipcMain.handle("password-status", () => ({ setup: fs.existsSync(vaultFile()), unlocked: !!vaultState, @@ -6561,6 +6679,7 @@ ipcMain.handle("password-unlock", async (_e, masterPassword) => { catch (ie) { console.error("[imports] unlock failed:", ie?.message); } } importsUnlockPw = masterPassword; + try { vaultPin().resetFails(); } catch {} emitPwAvailability(); return { ok: true, entries: v.listMetadata(vaultState) }; } catch (e) { return vaultErr(e?.message || e); } diff --git a/package.json b/package.json index 41e7b41f..25e6d1cd 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "sidebar-preload.js", "approval-preload.js", "approval.html", + "unlock.html", + "unlock-preload.js", "auth-prompt-preload.js", "auth-prompt.html", "addon-inject-preload.js", diff --git a/settings-preload.js b/settings-preload.js index 84bed9da..c51408d4 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -24,6 +24,12 @@ contextBridge.exposeInMainWorld("cfg", { pwUpdate: (id, patch) => ipcRenderer.invoke("password-update", id, patch), pwRemove: (id) => ipcRenderer.invoke("password-remove", id), pwGenerate: (spec) => ipcRenderer.invoke("password-generate", spec), + // Quick-unlock PIN. pinSet proves the master password in main before + // wrapping it; pinUnlock opens Theseus's own PIN / password prompt. + pinStatus: () => ipcRenderer.invoke("vault-pin-status"), + pinSet: (pin, masterPassword) => ipcRenderer.invoke("vault-pin-set", { pin, masterPassword }), + pinClear: () => ipcRenderer.invoke("vault-pin-clear"), + pinUnlock: () => ipcRenderer.invoke("vault-pin-unlock"), // Main asks settings to jump to a specific sidebar section (e.g. from the // engine picker's "Search settings…" click). Emits the section id string. onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)), diff --git a/settings.html b/settings.html index 52d8ce3d..e49a1045 100644 --- a/settings.html +++ b/settings.html @@ -586,6 +586,10 @@
+ +

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

Add an entry

@@ -1968,11 +1984,57 @@ async function pwRefresh() { const st = await C.pwStatus(); if (!st.setup) return pwShow("setup"); - if (!st.unlocked) return pwShow("locked"); + const pin = await C.pinStatus().catch(() => null); + if (!st.unlocked) { + document.getElementById("pwPinUnlockRow").hidden = !(pin && pin.pinSet); + return pwShow("locked"); + } pwShow("unlocked"); + renderPin(pin); const res = await C.pwList(); renderPwList(res.ok ? res.entries : []); } + // ---- Quick-unlock PIN ------------------------------------------------ + function renderPin(pin) { + const set = !!(pin && pin.pinSet); + 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." + : set && !pin.sealed ? base + " This system has no keystore to seal it with, so choose it carefully." + : base; + } + const pinForm = document.getElementById("pinForm"); + const pinErr = document.getElementById("pinErr"); + document.getElementById("pinSetBtn").onclick = () => { pinForm.hidden = false; pinErr.hidden = true; document.getElementById("pinMaster").focus(); }; + document.getElementById("pinCancel").onclick = () => { + pinForm.hidden = true; + for (const id of ["pinMaster", "pinNew1", "pinNew2"]) document.getElementById(id).value = ""; + }; + document.getElementById("pinSave").onclick = async () => { + pinErr.hidden = true; + const master = document.getElementById("pinMaster").value; + const p1 = document.getElementById("pinNew1").value; + const p2 = document.getElementById("pinNew2").value; + const fail = (m) => { pinErr.textContent = m; pinErr.hidden = false; }; + if (!/^\d{6}$/.test(p1)) return fail("The PIN must be 6 digits."); + if (p1 !== p2) return fail("The two PINs don't match."); + if (!master) return fail("Enter your master password to bind the PIN to it."); + const res = await C.pinSet(p1, master); + if (!res.ok) return fail(res.err === "wrong master password" ? "Wrong master password." : res.err); + document.getElementById("pinCancel").click(); + pwRefresh(); + }; + document.getElementById("pinClearBtn").onclick = async () => { + if (!confirm("Remove the PIN? The vault will need the master password again.")) return; + await C.pinClear(); + pwRefresh(); + }; + document.getElementById("pwPinUnlockBtn").onclick = async () => { + const r = await C.pinUnlock(); + if (r && r.ok) pwRefresh(); + }; function renderPwList(entries) { if (!entries.length) { pwListEl.innerHTML = `
No entries yet — add one below.
`; diff --git a/unlock-preload.js b/unlock-preload.js new file mode 100644 index 00000000..c410ae89 --- /dev/null +++ b/unlock-preload.js @@ -0,0 +1,10 @@ +// Preload for the vault unlock overlay (unlock.html). Main pushes one request +// via `unlock-show`; the page sends a PIN or the master password back with +// `unlock-submit` and main checks it. The secret goes straight to main — it +// is never handed to the add-on that asked for the unlock. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("unlock", { + onShow: (cb) => ipcRenderer.on("unlock-show", (_e, req) => cb(req)), + submit: (reqId, mode, value) => ipcRenderer.invoke("unlock-submit", reqId, String(mode || ""), String(value || "")), + cancel: (reqId) => ipcRenderer.invoke("unlock-cancel", reqId), +}); diff --git a/unlock.html b/unlock.html new file mode 100644 index 00000000..bcac544d --- /dev/null +++ b/unlock.html @@ -0,0 +1,149 @@ + + + + +Unlock + + + + + +