Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
This commit is contained in:
parent
3536cd89bd
commit
de7735feb3
8 changed files with 488 additions and 4 deletions
|
|
@ -16,7 +16,7 @@
|
||||||
// <userData>/extensions-data/<id>.json — per-add-on kv store (api.storage)
|
// <userData>/extensions-data/<id>.json — per-add-on kv store (api.storage)
|
||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const { storeFor } = require("./lib/addon-store.cjs");
|
const { storeFor } = require("./lib/addon-store.cjs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
// Extension points the framework understands. Extending this list means also
|
// Extension points the framework understands. Extending this list means also
|
||||||
|
|
@ -237,6 +237,9 @@ class AddonHost {
|
||||||
// to Settings > Passwords. Same "vault-derive" capability gate.
|
// to Settings > Passwords. Same "vault-derive" capability gate.
|
||||||
this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function"
|
this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function"
|
||||||
? arguments[0].vaultLifecycle : null;
|
? 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._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
|
||||||
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
||||||
this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null;
|
this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null;
|
||||||
|
|
@ -652,6 +655,17 @@ class AddonHost {
|
||||||
},
|
},
|
||||||
imports: this._makeImportsApi(manifest),
|
imports: this._makeImportsApi(manifest),
|
||||||
lifecycle: this._makeLifecycleApi(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
|
// approval-modal: ask the user. Resolves to the chosen action id, or
|
||||||
// "cancel" (Escape / mask click / window closed). With `checkbox` set
|
// "cancel" (Escape / mask click / window closed). With `checkbox` set
|
||||||
|
|
|
||||||
122
lib/vault-pin.cjs
Normal file
122
lib/vault-pin.cjs
Normal file
|
|
@ -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: <b64 safeStorage blob> | 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 };
|
||||||
123
main.js
123
main.js
|
|
@ -4,7 +4,7 @@
|
||||||
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
|
// 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
|
// page, and optional Tor onion routing. No system daemon; the app is the trust
|
||||||
// boundary.
|
// 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 path = require("path");
|
||||||
const url = require("url");
|
const url = require("url");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
@ -865,6 +865,7 @@ const SETTINGS_ONLY = new Set([
|
||||||
"password-setup", "password-status", "password-unlock", "password-update",
|
"password-setup", "password-status", "password-unlock", "password-update",
|
||||||
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
|
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
|
||||||
"settings-open-panel", "settings-section", "tor-state",
|
"settings-open-panel", "settings-section", "tor-state",
|
||||||
|
"vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock",
|
||||||
]);
|
]);
|
||||||
const SETTINGS_SHARED = new Set([
|
const SETTINGS_SHARED = new Set([
|
||||||
"add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state",
|
"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); }
|
catch (ie) { console.error("[imports] unlock via addon failed:", ie?.message); }
|
||||||
}
|
}
|
||||||
importsUnlockPw = masterPassword;
|
importsUnlockPw = masterPassword;
|
||||||
|
try { vaultPin().resetFails(); } catch {}
|
||||||
emitPwAvailability();
|
emitPwAvailability();
|
||||||
console.log(`[addons] [${addonId}] vault.unlock`);
|
console.log(`[addons] [${addonId}] vault.unlock`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -2486,6 +2488,7 @@ function initAddons() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
|
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
|
||||||
|
vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }),
|
||||||
emitToPanel: (addonId, msg, payload) => {
|
emitToPanel: (addonId, msg, payload) => {
|
||||||
// Full-tab pages of the same add-on hear it too. addon-tab-preload has
|
// 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,
|
// 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
|
// Approval overlay sits exactly over the tab area — the page underneath
|
||||||
// keeps running; only pointer input is intercepted.
|
// keeps running; only pointer input is intercepted.
|
||||||
if (approvalPop) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
|
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 });
|
if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
|
||||||
positionPopover();
|
positionPopover();
|
||||||
positionEnginePicker();
|
positionEnginePicker();
|
||||||
|
|
@ -4180,6 +4186,12 @@ function createWindow() {
|
||||||
styleScrollbars(approvalPop.webContents);
|
styleScrollbars(approvalPop.webContents);
|
||||||
deferOverlayLoad(approvalPop, "approval.html");
|
deferOverlayLoad(approvalPop, "approval.html");
|
||||||
approvalPop.setVisible(false);
|
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.
|
// Page dialogs (alert / confirm / prompt) — see the js-dialog block.
|
||||||
jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } });
|
jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } });
|
||||||
try { jsDialogPop.setBackgroundColor("#00000000"); } catch {}
|
try { jsDialogPop.setBackgroundColor("#00000000"); } catch {}
|
||||||
|
|
@ -4222,7 +4234,7 @@ function createWindow() {
|
||||||
// pumpApproval (and every later dapp request) until a full restart.
|
// pumpApproval (and every later dapp request) until a full restart.
|
||||||
if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} }
|
if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} }
|
||||||
for (const q of approvalQueue.splice(0)) { try { q.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;
|
linkStatusVisible = false;
|
||||||
});
|
});
|
||||||
layout();
|
layout();
|
||||||
|
|
@ -5704,6 +5716,7 @@ ipcMain.handle("jsdialog-answer", (e, reqId, ok, value) => {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
let approvalPop = null;
|
let approvalPop = null;
|
||||||
|
let unlockPop = null; // vault unlock prompt (unlock.html), see requestVaultUnlock
|
||||||
const approvalQueue = [];
|
const approvalQueue = [];
|
||||||
let approvalCurrent = null; // { reqId, resolve }
|
let approvalCurrent = null; // { reqId, resolve }
|
||||||
let approvalSeq = 0;
|
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 vaultOk = () => ({ ok: true });
|
||||||
const vaultErr = (m) => ({ ok: false, err: String(m) });
|
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", () => ({
|
ipcMain.handle("password-status", () => ({
|
||||||
setup: fs.existsSync(vaultFile()),
|
setup: fs.existsSync(vaultFile()),
|
||||||
unlocked: !!vaultState,
|
unlocked: !!vaultState,
|
||||||
|
|
@ -6561,6 +6679,7 @@ ipcMain.handle("password-unlock", async (_e, masterPassword) => {
|
||||||
catch (ie) { console.error("[imports] unlock failed:", ie?.message); }
|
catch (ie) { console.error("[imports] unlock failed:", ie?.message); }
|
||||||
}
|
}
|
||||||
importsUnlockPw = masterPassword;
|
importsUnlockPw = masterPassword;
|
||||||
|
try { vaultPin().resetFails(); } catch {}
|
||||||
emitPwAvailability();
|
emitPwAvailability();
|
||||||
return { ok: true, entries: v.listMetadata(vaultState) };
|
return { ok: true, entries: v.listMetadata(vaultState) };
|
||||||
} catch (e) { return vaultErr(e?.message || e); }
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@
|
||||||
"sidebar-preload.js",
|
"sidebar-preload.js",
|
||||||
"approval-preload.js",
|
"approval-preload.js",
|
||||||
"approval.html",
|
"approval.html",
|
||||||
|
"unlock.html",
|
||||||
|
"unlock-preload.js",
|
||||||
"auth-prompt-preload.js",
|
"auth-prompt-preload.js",
|
||||||
"auth-prompt.html",
|
"auth-prompt.html",
|
||||||
"addon-inject-preload.js",
|
"addon-inject-preload.js",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ contextBridge.exposeInMainWorld("cfg", {
|
||||||
pwUpdate: (id, patch) => ipcRenderer.invoke("password-update", id, patch),
|
pwUpdate: (id, patch) => ipcRenderer.invoke("password-update", id, patch),
|
||||||
pwRemove: (id) => ipcRenderer.invoke("password-remove", id),
|
pwRemove: (id) => ipcRenderer.invoke("password-remove", id),
|
||||||
pwGenerate: (spec) => ipcRenderer.invoke("password-generate", spec),
|
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
|
// 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.
|
// engine picker's "Search settings…" click). Emits the section id string.
|
||||||
onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)),
|
onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)),
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,10 @@
|
||||||
<div class="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
|
<div class="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
|
||||||
</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="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="ctl"><button id="pwPinUnlockBtn" class="btn" type="button">Unlock with PIN</button></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- State C: vault unlocked -->
|
<!-- State C: vault unlocked -->
|
||||||
<div id="pwUnlocked" hidden>
|
<div id="pwUnlocked" hidden>
|
||||||
|
|
@ -593,6 +597,18 @@
|
||||||
<div class="txt"><div class="t">Your passwords</div><div class="d">Reveal, copy, or edit any entry. The vault re-locks when Theseus quits.</div></div>
|
<div class="txt"><div class="t">Your passwords</div><div class="d">Reveal, copy, or edit any entry. The vault re-locks when Theseus quits.</div></div>
|
||||||
<button id="pwLockBtn" class="btn" type="button">Lock now</button>
|
<button id="pwLockBtn" class="btn" type="button">Lock now</button>
|
||||||
</div>
|
</div>
|
||||||
|
<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="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>
|
||||||
|
<div class="addeng"><input id="pinMaster" type="password" placeholder="Master password" autocomplete="current-password"></div>
|
||||||
|
<div class="addeng"><input id="pinNew1" type="password" inputmode="numeric" maxlength="6" placeholder="New 6-digit PIN" autocomplete="off"><input id="pinNew2" type="password" inputmode="numeric" maxlength="6" placeholder="Repeat PIN" autocomplete="off"></div>
|
||||||
|
<div id="pinErr" class="pmuted" style="color:#f6768a;font-size:12.5px" hidden></div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:6px"><button id="pinCancel" class="btn" type="button">Cancel</button><button id="pinSave" class="btn" type="button">Save PIN</button></div>
|
||||||
|
</div>
|
||||||
<div id="pwList"></div>
|
<div id="pwList"></div>
|
||||||
<h2 class="sub">Add an entry</h2>
|
<h2 class="sub">Add an entry</h2>
|
||||||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||||
|
|
@ -1968,11 +1984,57 @@
|
||||||
async function pwRefresh() {
|
async function pwRefresh() {
|
||||||
const st = await C.pwStatus();
|
const st = await C.pwStatus();
|
||||||
if (!st.setup) return pwShow("setup");
|
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");
|
pwShow("unlocked");
|
||||||
|
renderPin(pin);
|
||||||
const res = await C.pwList();
|
const res = await C.pwList();
|
||||||
renderPwList(res.ok ? res.entries : []);
|
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) {
|
function renderPwList(entries) {
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
pwListEl.innerHTML = `<div class="cempty" style="padding:12px 0">No entries yet — add one below.</div>`;
|
pwListEl.innerHTML = `<div class="cempty" style="padding:12px 0">No entries yet — add one below.</div>`;
|
||||||
|
|
|
||||||
10
unlock-preload.js
Normal file
10
unlock-preload.js
Normal file
|
|
@ -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),
|
||||||
|
});
|
||||||
149
unlock.html
Normal file
149
unlock.html
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Unlock</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark;
|
||||||
|
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
|
||||||
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
|
||||||
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid: #0AC18E; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
|
||||||
|
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||||
|
display: grid; place-items: start center; padding-top: 48px; }
|
||||||
|
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
|
||||||
|
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
|
||||||
|
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||||
|
.who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
|
||||||
|
.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); }
|
||||||
|
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; }
|
||||||
|
.err { color: var(--danger); font-size: 12.5px; min-height: 18px; margin-bottom: 6px; text-align: center; }
|
||||||
|
.pact { display: flex; gap: 6px; justify-content: space-between; align-items: center; }
|
||||||
|
.link { background: none; border: none; color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px; padding: 0; text-decoration: underline; }
|
||||||
|
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
|
||||||
|
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
|
||||||
|
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
|
||||||
|
.pbtn:disabled { opacity: .5; cursor: default; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Built with createElement/textContent only: the add-on supplies the
|
||||||
|
// reason text, and it must never be able to inject markup into a prompt
|
||||||
|
// that asks for the master password.
|
||||||
|
const el = (tag, attrs = {}, ...kids) => {
|
||||||
|
const n = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === "class") n.className = v;
|
||||||
|
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
|
||||||
|
else n.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c));
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
let req = null;
|
||||||
|
let mode = "pin";
|
||||||
|
let pin = "";
|
||||||
|
let busy = false;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
function done(cancelled) {
|
||||||
|
if (!req) return;
|
||||||
|
const id = req.reqId;
|
||||||
|
req = null;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
if (cancelled) window.unlock.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(value) {
|
||||||
|
if (!req || busy) return;
|
||||||
|
busy = true;
|
||||||
|
render();
|
||||||
|
const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message }));
|
||||||
|
busy = false;
|
||||||
|
if (r && r.ok) { req = null; document.body.replaceChildren(); return; }
|
||||||
|
error = (r && r.error) || "Could not unlock";
|
||||||
|
if (r && r.mode) mode = r.mode;
|
||||||
|
pin = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(d) {
|
||||||
|
if (busy) return;
|
||||||
|
if (d === "back") pin = pin.slice(0, -1);
|
||||||
|
else if (pin.length < 6) pin += d;
|
||||||
|
error = "";
|
||||||
|
render();
|
||||||
|
if (pin.length === 6) submit(pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!req) return;
|
||||||
|
const head = [
|
||||||
|
el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"), el("span", {}, "asks to unlock your vault")),
|
||||||
|
el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"),
|
||||||
|
req.reason ? el("p", { class: "reason" }, req.reason) : null,
|
||||||
|
];
|
||||||
|
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,
|
||||||
|
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"))];
|
||||||
|
} else {
|
||||||
|
const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
|
||||||
|
const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock");
|
||||||
|
if (busy) go.setAttribute("disabled", "");
|
||||||
|
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"),
|
||||||
|
el("div", { style: "display:flex;gap:6px" },
|
||||||
|
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
||||||
|
bodyEls = [form];
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
document.body.replaceChildren(el("div", { class: "promptmask", onmousedown: (e) => { if (e.target.classList.contains("promptmask")) done(true); } },
|
||||||
|
el("div", { class: "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.unlock.onShow((r) => {
|
||||||
|
req = r;
|
||||||
|
mode = r.pinSet && !r.requireMaster ? "pin" : "password";
|
||||||
|
pin = "";
|
||||||
|
error = r.requireMaster && r.pinSet ? "Too many wrong PINs. Enter the master password." : "";
|
||||||
|
busy = false;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (!req) return;
|
||||||
|
if (e.key === "Escape") return done(true);
|
||||||
|
if (mode === "pin" && /^[0-9]$/.test(e.key)) press(e.key);
|
||||||
|
else if (mode === "pin" && e.key === "Backspace") press("back");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue