Merge release/0.3.80: Theseus ID, guided PIN, save-login offers
This commit is contained in:
commit
869b94a85a
15 changed files with 1720 additions and 112 deletions
|
|
@ -75,7 +75,9 @@
|
||||||
const hasCancel = actions.some((a) => a.id === "cancel");
|
const hasCancel = actions.some((a) => a.id === "cancel");
|
||||||
document.body.innerHTML =
|
document.body.innerHTML =
|
||||||
`<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true">
|
`<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true">
|
||||||
<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div>
|
${req.builtin
|
||||||
|
? `<div class="who"><span>🔑</span><span class="addon">${esc(req.addonName || "Theseus")}</span></div>`
|
||||||
|
: `<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div>`}
|
||||||
<h1 class="title">${esc(req.title || "Approve?")}</h1>
|
<h1 class="title">${esc(req.title || "Approve?")}</h1>
|
||||||
${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""}
|
${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""}
|
||||||
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}
|
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}
|
||||||
|
|
|
||||||
49
dev/signin-sites.test.cjs
Normal file
49
dev/signin-sites.test.cjs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// node --test TheseusNavigator/dev/signin-sites.test.cjs
|
||||||
|
"use strict";
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { createSigninSites, keepOrigins, normHost } = require("../lib/signin-sites.cjs");
|
||||||
|
|
||||||
|
const fakeSafe = {
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
getSelectedStorageBackend: () => "gnome_libsecret",
|
||||||
|
encryptString: (s) => Buffer.from("SEALED:" + s),
|
||||||
|
decryptString: (b) => { const s = b.toString(); if (!s.startsWith("SEALED:")) throw new Error("bad seal"); return s.slice(7); },
|
||||||
|
};
|
||||||
|
const tmpFile = () => path.join(fs.mkdtempSync(path.join(os.tmpdir(), "sis-")), "signin-sites.json");
|
||||||
|
|
||||||
|
test("keepOrigins: www twin for names, none for IPs or one-label hosts", () => {
|
||||||
|
assert.deepEqual(keepOrigins(["github.com"]).sort(),
|
||||||
|
["http://github.com", "http://www.github.com", "https://github.com", "https://www.github.com"]);
|
||||||
|
assert.deepEqual(keepOrigins(["www.example.org"]).sort(),
|
||||||
|
["http://example.org", "http://www.example.org", "https://example.org", "https://www.example.org"]);
|
||||||
|
assert.deepEqual(keepOrigins(["127.0.0.1"]).sort(), ["http://127.0.0.1", "https://127.0.0.1"]);
|
||||||
|
assert.deepEqual(keepOrigins(["localhost"]).sort(), ["http://localhost", "https://localhost"]);
|
||||||
|
assert.deepEqual(keepOrigins(["bad host", "", "a/b", "evil.com:80"]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normHost lower-cases and trims a trailing dot", () => {
|
||||||
|
assert.equal(normHost("GitHub.COM."), "github.com");
|
||||||
|
assert.equal(normHost("x y"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the list is sealed on disk and survives a reload", () => {
|
||||||
|
const file = tmpFile();
|
||||||
|
const a = createSigninSites({ file, safeStorage: fakeSafe });
|
||||||
|
assert.equal(a.save(["b.com", "a.com", "a.com", "nope nope"]), true);
|
||||||
|
assert.ok(!fs.readFileSync(file, "utf8").includes("a.com"), "hostnames must not be readable in the file");
|
||||||
|
const b = createSigninSites({ file, safeStorage: fakeSafe });
|
||||||
|
assert.deepEqual(b.load(), ["a.com", "b.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without an OS keystore nothing is stored", () => {
|
||||||
|
const file = tmpFile();
|
||||||
|
const noSafe = { isEncryptionAvailable: () => false };
|
||||||
|
const s = createSigninSites({ file, safeStorage: noSafe });
|
||||||
|
assert.equal(s.save(["a.com"]), false);
|
||||||
|
assert.equal(fs.existsSync(file), false);
|
||||||
|
assert.deepEqual(createSigninSites({ file, safeStorage: noSafe }).load(), []);
|
||||||
|
});
|
||||||
153
dev/theseus-id.test.cjs
Normal file
153
dev/theseus-id.test.cjs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// node --test TheseusNavigator/dev/theseus-id.test.cjs
|
||||||
|
// lib/theseus-id.cjs with a fake vault, fake prompts and fake fetchers.
|
||||||
|
"use strict";
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
const { createTheseusIdHost, SILENT_PER_MIN } = require("../lib/theseus-id.cjs");
|
||||||
|
|
||||||
|
const LIB = pathToFileURL(path.join(__dirname, "..", "..", "TheseusID", "lib", "index.mjs")).href;
|
||||||
|
async function loadLib() {
|
||||||
|
const lib = await import(LIB);
|
||||||
|
const { secp256k1 } = await import("@noble/curves/secp256k1.js");
|
||||||
|
const { sha256 } = await import("@noble/hashes/sha2.js");
|
||||||
|
const { ripemd160 } = await import("@noble/hashes/legacy.js");
|
||||||
|
const { hkdf } = await import("@noble/hashes/hkdf.js");
|
||||||
|
return { lib, crypto: lib.createCrypto({ secp256k1, sha256, ripemd160, hkdf }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGISTRY = {
|
||||||
|
v: 1, seq: 1,
|
||||||
|
projects: [
|
||||||
|
{ project: "hephaestus", name: "Hephaestus", origins: ["https://code.silentmode.st"], provider: { issuer: "https://accounts.silentmode.st", client_id: "hephaestus" }, supports: ["move"] },
|
||||||
|
{ project: "sirius", name: "Sirius", origins: ["https://sirius.x"] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup({ unlocked = true, confirm, docs = {} } = {}) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tid-"));
|
||||||
|
const vault = { root: unlocked ? new Uint8Array(32).fill(5) : null };
|
||||||
|
const prompts = [];
|
||||||
|
const host = createTheseusIdHost({
|
||||||
|
file: path.join(dir, "theseus-id.json"),
|
||||||
|
loadLib,
|
||||||
|
getPurposeRoot: () => vault.root,
|
||||||
|
hasVault: () => true,
|
||||||
|
bundledRegistry: REGISTRY,
|
||||||
|
fetchOriginDoc: async (authority) => { if (!docs[authority]) throw new Error("404"); return docs[authority]; },
|
||||||
|
ui: {
|
||||||
|
unlock: async () => { vault.root = new Uint8Array(32).fill(5); return true; },
|
||||||
|
confirm: async (req) => { prompts.push(req); return confirm ? confirm(req) : { ok: true }; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { host, vault, prompts, dir };
|
||||||
|
}
|
||||||
|
const req = (over = {}) => ({ projectId: "hephaestus", nonce: "nonce-0123456789abcdef", origin: "https://code.silentmode.st", uri: "https://code.silentmode.st/login", gesture: true, ...over });
|
||||||
|
|
||||||
|
test("first sign-in prompts, later first-party sign-ins are silent", async () => {
|
||||||
|
const { host, prompts } = setup();
|
||||||
|
const a = await host.signIn(req());
|
||||||
|
assert.equal(prompts.length, 1);
|
||||||
|
assert.equal(prompts[0].first, true);
|
||||||
|
assert.match(a.account, /^tid:/);
|
||||||
|
const { lib, crypto } = await loadLib();
|
||||||
|
assert.ok(crypto.verifyAddress(a.message, a.signature, a.account));
|
||||||
|
assert.equal(lib.parseMessage(a.message).origin, "https://code.silentmode.st");
|
||||||
|
const b = await host.signIn(req({ nonce: "nonce-0123456789abcdeg" }));
|
||||||
|
assert.equal(prompts.length, 1, "no second prompt");
|
||||||
|
assert.equal(b.account, a.account, "same ID every time");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("origins outside the list are refused without any prompt", async () => {
|
||||||
|
const { host, prompts } = setup();
|
||||||
|
await assert.rejects(host.signIn(req({ origin: "https://evil.example" })), { code: "origin-not-listed" });
|
||||||
|
await assert.rejects(host.signIn(req({ origin: "https://navigate.st" })), { code: "origin-not-listed" });
|
||||||
|
await assert.rejects(host.signIn(req({ projectId: "unknown" })), { code: "origin-not-listed" });
|
||||||
|
assert.equal(prompts.length, 0);
|
||||||
|
// The listed provider may ask for Hephaestus's signature.
|
||||||
|
const viaProvider = await host.signIn(req({ origin: "https://accounts.silentmode.st" }));
|
||||||
|
assert.match(viaProvider.message, /Origin: https:\/\/accounts\.silentmode\.st/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("per-project IDs differ; One ID is shared", async () => {
|
||||||
|
const { host } = setup({ confirm: (r) => ({ ok: true, mode: r.projectId === "sirius" ? "one" : "project" }) });
|
||||||
|
const h = await host.signIn(req());
|
||||||
|
const s = await host.signIn(req({ projectId: "sirius", origin: "https://sirius.x", uri: "https://sirius.x/" }));
|
||||||
|
assert.notEqual(h.account, s.account);
|
||||||
|
const ov = await host.overview();
|
||||||
|
assert.equal(ov.oneId, s.account, "sirius chose One ID");
|
||||||
|
assert.deepEqual(ov.projects.map((p) => p.mode).sort(), ["one", "project"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("locked vault: no gesture, no prompt; with a gesture, unlock first", async () => {
|
||||||
|
const { host, vault } = setup({ unlocked: false });
|
||||||
|
await assert.rejects(host.signIn(req({ gesture: false })), { code: "locked" });
|
||||||
|
assert.equal(vault.root, null);
|
||||||
|
const r = await host.signIn(req());
|
||||||
|
assert.match(r.account, /^tid:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declining, busy and the silent-rate limit", async () => {
|
||||||
|
const { host, prompts } = setup({ confirm: () => ({ ok: false }) });
|
||||||
|
await assert.rejects(host.signIn(req()), { code: "denied" });
|
||||||
|
const s2 = setup();
|
||||||
|
await s2.host.signIn(req());
|
||||||
|
const both = await Promise.allSettled([s2.host.signIn(req({ nonce: "nonce-aaaaaaaaaaaaaaaa" })), s2.host.signIn(req({ nonce: "nonce-bbbbbbbbbbbbbbbb" }))]);
|
||||||
|
assert.ok(both.some((x) => x.status === "rejected" && x.reason.code === "busy"));
|
||||||
|
for (let i = 0; i < SILENT_PER_MIN + 2; i++) await s2.host.signIn(req({ nonce: "nonce-cccccccccccccc" + String(i).padStart(2, "0") }));
|
||||||
|
assert.ok(s2.prompts.length >= 2, "beyond the limit, sign-ins prompt again");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a mode switch never changes the ID silently: move proof, then finish", async () => {
|
||||||
|
const { host } = setup();
|
||||||
|
const first = await host.signIn(req());
|
||||||
|
await host.setDefaultMode("one");
|
||||||
|
const still = await host.signIn(req({ nonce: "nonce-dddddddddddddddd" }));
|
||||||
|
assert.equal(still.account, first.account, "default mode change leaves existing projects alone");
|
||||||
|
await host.requestMove("hephaestus", { mode: "one" });
|
||||||
|
const moving = await host.signIn(req({ nonce: "nonce-eeeeeeeeeeeeeeee" }));
|
||||||
|
assert.notEqual(moving.account, first.account);
|
||||||
|
assert.equal(moving.move.from, first.account);
|
||||||
|
const { lib, crypto } = await loadLib();
|
||||||
|
const v = lib.createVerifier({ crypto, projectId: "hephaestus", origins: ["https://code.silentmode.st"], consumeNonce: () => true });
|
||||||
|
const ok = await v.verifySignIn({ message: moving.message, signature: moving.signature, move: moving.move });
|
||||||
|
assert.equal(ok.movedFrom, first.account);
|
||||||
|
await host.moved({ projectId: "hephaestus", origin: "https://code.silentmode.st", account: moving.account });
|
||||||
|
const after = await host.signIn(req({ nonce: "nonce-ffffffffffffffff" }));
|
||||||
|
assert.equal(after.account, moving.account);
|
||||||
|
assert.equal(after.move, undefined);
|
||||||
|
// Sirius does not list "move": refused.
|
||||||
|
await host.signIn(req({ projectId: "sirius", origin: "https://sirius.x" }));
|
||||||
|
await assert.rejects(host.requestMove("sirius", { mode: "one" }), { code: "move-unsupported" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("state is encrypted, survives a restart, and is unreadable with another vault", async () => {
|
||||||
|
const s = setup();
|
||||||
|
const a = await s.host.signIn(req());
|
||||||
|
const raw = fs.readFileSync(path.join(s.dir, "theseus-id.json"), "utf8");
|
||||||
|
assert.ok(!raw.includes("hephaestus") && !raw.includes(a.account), "no plain project names or IDs on disk");
|
||||||
|
s.host.forget();
|
||||||
|
const ov = await s.host.overview();
|
||||||
|
assert.equal(ov.projects[0].account, a.account);
|
||||||
|
s.vault.root = new Uint8Array(32).fill(9); s.host.forget();
|
||||||
|
assert.deepEqual((await s.host.overview()).projects, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("name-scoped projects fetch their list; BNS lists must be owner-signed", async () => {
|
||||||
|
const { lib, crypto } = await loadLib();
|
||||||
|
const ownerKey = new Uint8Array(32).fill(3);
|
||||||
|
const owner = crypto.account(ownerKey, "bitcoincash");
|
||||||
|
const body = { v: 1, project: "game.x", origins: ["https://game.x"] };
|
||||||
|
const signed = { ...body, sig: crypto.sign(ownerKey, lib.canonicalJson(body)) };
|
||||||
|
const { host } = setup({ docs: {
|
||||||
|
"game.x": { doc: signed, bns: true, owner },
|
||||||
|
"blog.example.org": { doc: { v: 1, project: "sirius-press:blog.example.org", origins: ["https://blog.example.org"] }, bns: false },
|
||||||
|
"bad.x": { doc: body, bns: true, owner },
|
||||||
|
} });
|
||||||
|
assert.match((await host.signIn(req({ projectId: "game.x", origin: "https://game.x" }))).account, /^tid:/);
|
||||||
|
assert.match((await host.signIn(req({ projectId: "sirius-press:blog.example.org", origin: "https://blog.example.org" }))).account, /^tid:/);
|
||||||
|
await assert.rejects(host.signIn(req({ projectId: "bad.x", origin: "https://bad.x" })), { code: "origin-list-unavailable" });
|
||||||
|
});
|
||||||
66
dev/vault-pin.test.cjs
Normal file
66
dev/vault-pin.test.cjs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// node --test TheseusNavigator/dev/vault-pin.test.cjs
|
||||||
|
// lib/vault-pin.cjs with a fake OS keystore and no TPM, so a wrong PIN here
|
||||||
|
// never costs a real TPM dictionary-attack strike.
|
||||||
|
"use strict";
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { createVaultPin, MAX_FAILS, PIN_MIN, PIN_MAX } = require("../lib/vault-pin.cjs");
|
||||||
|
|
||||||
|
const fakeSafe = {
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
getSelectedStorageBackend: () => "gnome_libsecret",
|
||||||
|
encryptString: (s) => Buffer.from("SEALED:" + s),
|
||||||
|
decryptString: (b) => { const s = b.toString(); if (!s.startsWith("SEALED:")) throw new Error("bad seal"); return s.slice(7); },
|
||||||
|
};
|
||||||
|
const noTpm = { supported: () => false };
|
||||||
|
const make = () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vpin-"));
|
||||||
|
const file = path.join(dir, "vault-pin.json");
|
||||||
|
return Object.assign(createVaultPin({ file, safeStorage: fakeSafe, tpm: noTpm }), { file });
|
||||||
|
};
|
||||||
|
|
||||||
|
test("lengths 6 to 8 are accepted, others refused", async () => {
|
||||||
|
assert.equal(PIN_MIN, 6); assert.equal(PIN_MAX, 8);
|
||||||
|
const p = make();
|
||||||
|
for (const bad of ["12345", "123456789", "12a456", ""]) await assert.rejects(p.set(bad, "pw"), /6 to 8 digits/);
|
||||||
|
for (const good of ["123456", "1234567", "12345678"]) {
|
||||||
|
await p.set(good, "master");
|
||||||
|
assert.equal(p.status().length, good.length);
|
||||||
|
assert.equal(await p.open(good), "master");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a wrong-length PIN costs no strike", async () => {
|
||||||
|
const p = make();
|
||||||
|
await p.set("12345678", "master");
|
||||||
|
for (let i = 0; i < MAX_FAILS + 2; i++) {
|
||||||
|
await assert.rejects(p.open("123456"), (e) => e.code === "wrong-length" && e.length === 8);
|
||||||
|
}
|
||||||
|
assert.equal(p.status().fails, 0);
|
||||||
|
assert.equal(await p.open("12345678"), "master");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrong PINs of the right length still lock after MAX_FAILS", async () => {
|
||||||
|
const p = make();
|
||||||
|
await p.set("1234567", "master");
|
||||||
|
for (let i = 1; i < MAX_FAILS; i++) await assert.rejects(p.open("7654321"), (e) => e.code === "wrong-pin" && e.remaining === MAX_FAILS - i);
|
||||||
|
await assert.rejects(p.open("7654321"), (e) => e.code === "locked");
|
||||||
|
await assert.rejects(p.open("1234567"), (e) => e.code === "locked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records from 6-only builds (no len) read as 6 digits", async () => {
|
||||||
|
const p = make();
|
||||||
|
await p.set("123456", "master");
|
||||||
|
// Strip len, as an older build would have written it.
|
||||||
|
const rec = JSON.parse(fs.readFileSync(p.file, "utf8"));
|
||||||
|
const blob = JSON.parse(fakeSafe.decryptString(Buffer.from(rec.data, "base64")));
|
||||||
|
delete blob.len;
|
||||||
|
rec.data = fakeSafe.encryptString(JSON.stringify(blob)).toString("base64");
|
||||||
|
fs.writeFileSync(p.file, JSON.stringify(rec));
|
||||||
|
assert.equal(p.status().length, 6);
|
||||||
|
assert.equal(await p.open("123456"), "master");
|
||||||
|
await assert.rejects(p.open("1234567"), (e) => e.code === "wrong-length");
|
||||||
|
});
|
||||||
|
|
@ -31,3 +31,76 @@ contextBridge.exposeInMainWorld("errorpage", {
|
||||||
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
||||||
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Password manager hooks -------------------------------------------------
|
||||||
|
// Runs in this preload's isolated world on every web page in a tab; nothing
|
||||||
|
// is exposed to the page. Two reports go to main, which takes the site from
|
||||||
|
// the tab's committed URL, never from here:
|
||||||
|
// pw-form a login field got focus -> main may offer saved logins under it
|
||||||
|
// pw-capture a form carrying a password was sent -> main may offer to save it
|
||||||
|
(() => {
|
||||||
|
if (!/^(https?|bns):$/.test(location.protocol)) return;
|
||||||
|
const visible = (el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width < 4 || r.height < 4) return false;
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
return cs.visibility !== "hidden" && cs.display !== "none";
|
||||||
|
};
|
||||||
|
const TEXTY = /^(text|email|tel|)$/i;
|
||||||
|
const passwords = (scope) => [...(scope || document).querySelectorAll("input[type=password]")].filter((el) => !el.disabled && visible(el));
|
||||||
|
// The username is the closest text-like field before the password in the
|
||||||
|
// same form (or page), which is how almost every login form is laid out.
|
||||||
|
function usernameFor(pw) {
|
||||||
|
const scope = pw.form || document;
|
||||||
|
const inputs = [...scope.querySelectorAll("input")].filter((el) => !el.disabled && visible(el));
|
||||||
|
const at = inputs.indexOf(pw);
|
||||||
|
for (let i = at - 1; i >= 0; i--) if (TEXTY.test(inputs[i].type || "text")) return inputs[i];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function capture() {
|
||||||
|
try {
|
||||||
|
const pws = passwords().filter((el) => el.value);
|
||||||
|
if (!pws.length) return;
|
||||||
|
// Sign-up: password + confirmation (same value). Change-password:
|
||||||
|
// current, new[, confirm] -> the new one is second.
|
||||||
|
const signup = pws.length >= 2 || /new-password/i.test(pws[0].autocomplete || "");
|
||||||
|
const pw = pws.length >= 3 ? pws[1] : pws.length === 2 && pws[0].value !== pws[1].value ? pws[1] : pws[0];
|
||||||
|
const user = usernameFor(pws[0]);
|
||||||
|
ipcRenderer.send("pw-capture", { username: user ? user.value : "", password: pw.value, signup });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
document.addEventListener("submit", capture, true);
|
||||||
|
// Script-driven logins never fire submit: catch the button press and Enter.
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const b = e.target instanceof Element ? e.target.closest("button, input[type=submit], input[type=button], [role=button]") : null;
|
||||||
|
if (b && passwords().some((el) => el.value)) capture();
|
||||||
|
}, true);
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && e.target instanceof HTMLInputElement && (e.target.type === "password" || TEXTY.test(e.target.type))) {
|
||||||
|
if (passwords().some((el) => el.value)) capture();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// A focused username or password field of a login form (one password
|
||||||
|
// field, empty) asks main to show saved logins under it.
|
||||||
|
function loginField(el) {
|
||||||
|
if (!(el instanceof HTMLInputElement) || el.disabled || el.readOnly) return null;
|
||||||
|
if (el.type === "password") return passwords(el.form || document).length === 1 ? el : null;
|
||||||
|
if (!TEXTY.test(el.type || "text")) return null;
|
||||||
|
const pws = passwords(el.form || document);
|
||||||
|
return pws.length === 1 && usernameFor(pws[0]) === el ? el : null;
|
||||||
|
}
|
||||||
|
function offer(el) {
|
||||||
|
if (!el || el.value) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
ipcRenderer.send("pw-form", { x: r.left, y: r.top, h: r.height });
|
||||||
|
}
|
||||||
|
document.addEventListener("focusin", (e) => offer(loginField(e.target)), true);
|
||||||
|
// Typing means the user is not taking the offer.
|
||||||
|
document.addEventListener("input", (e) => { if (loginField(e.target)) ipcRenderer.send("pw-form-dismiss"); }, true);
|
||||||
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") ipcRenderer.send("pw-form-dismiss"); }, true);
|
||||||
|
// An autofocused field is already focused when this runs.
|
||||||
|
const early = () => offer(loginField(document.activeElement));
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", early, { once: true });
|
||||||
|
else early();
|
||||||
|
})();
|
||||||
|
|
|
||||||
83
lib/signin-sites.cjs
Normal file
83
lib/signin-sites.cjs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Sites whose sign-in survives "Clear cookies on quit".
|
||||||
|
//
|
||||||
|
// The vault knows which sites have a saved login, but it is locked by the
|
||||||
|
// time Theseus quits (and often for the whole session). So main keeps a copy
|
||||||
|
// of just the hostnames here, refreshed whenever the vault is open, sealed
|
||||||
|
// with Electron safeStorage (DPAPI / Keychain / libsecret) so it is not a
|
||||||
|
// plain-text list of the user's accounts on disk. Without a real OS keystore
|
||||||
|
// nothing is stored and every cookie is cleared as before.
|
||||||
|
//
|
||||||
|
// keepOrigins() turns the list into the origins Session.clearData() should
|
||||||
|
// leave alone. Chromium matches cookies at the registrable-domain level, so
|
||||||
|
// one origin per host covers the site's cookies; storage (localStorage,
|
||||||
|
// IndexedDB) is per origin, so the bare and www. forms are both listed.
|
||||||
|
//
|
||||||
|
// File: { v: 1, data: <b64 safeStorage blob of JSON string[]> }
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const HOST_RE = /^(?=.{1,253}$)[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
||||||
|
|
||||||
|
function normHost(h) {
|
||||||
|
const s = String(h || "").trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
return HOST_RE.test(s) ? s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepOrigins(hosts) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const raw of hosts || []) {
|
||||||
|
const h = normHost(raw);
|
||||||
|
if (!h) continue;
|
||||||
|
// No www. twin for an IP address or a one-label host: Chromium rejects
|
||||||
|
// "www.127.0.0.1" as an origin, and one bad origin fails the whole call.
|
||||||
|
const plain = /^\d+(\.\d+){3}$/.test(h) || !h.includes(".");
|
||||||
|
for (const host of plain ? [h] : h.startsWith("www.") ? [h, h.slice(4)] : [h, "www." + h]) {
|
||||||
|
out.add("https://" + host);
|
||||||
|
out.add("http://" + host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSigninSites({ file, safeStorage, log = () => {} }) {
|
||||||
|
const sealOk = () => {
|
||||||
|
try {
|
||||||
|
if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false;
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const b = typeof safeStorage.getSelectedStorageBackend === "function" ? safeStorage.getSelectedStorageBackend() : "unknown";
|
||||||
|
if (b === "basic_text" || b === "unknown") return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (cache) return cache.slice();
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
const rec = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
if (rec && rec.v === 1 && rec.data && sealOk()) list = JSON.parse(safeStorage.decryptString(Buffer.from(rec.data, "base64")));
|
||||||
|
} catch { list = []; }
|
||||||
|
cache = Array.isArray(list) ? list.map(normHost).filter(Boolean) : [];
|
||||||
|
return cache.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(hosts) {
|
||||||
|
const list = [...new Set((hosts || []).map(normHost).filter(Boolean))].sort();
|
||||||
|
cache = list;
|
||||||
|
if (!sealOk()) { try { fs.unlinkSync(file); } catch {} return false; }
|
||||||
|
try {
|
||||||
|
const tmp = file + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, data: safeStorage.encryptString(JSON.stringify(list)).toString("base64") }), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
return true;
|
||||||
|
} catch (e) { log("signin-sites: save failed:", e?.message || e); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, save, keepOrigins: () => keepOrigins(load()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSigninSites, keepOrigins, normHost };
|
||||||
310
lib/theseus-id.cjs
Normal file
310
lib/theseus-id.cjs
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
// Theseus ID inside Theseus's main process (DESIGN-theseus-id.md §5, §6).
|
||||||
|
//
|
||||||
|
// What lives here: the sign-in policy (which origin may sign as which
|
||||||
|
// project, when to prompt, when to stay silent), the encrypted record of
|
||||||
|
// which ID each project got, and the origin-list cache. What does not: any
|
||||||
|
// UI or Electron object. main.js passes in the vault, the prompts and the
|
||||||
|
// fetchers, which keeps this testable with fakes (dev/theseus-id.test.cjs).
|
||||||
|
//
|
||||||
|
// Identity keys are derived per signature and zeroed after it. They never
|
||||||
|
// leave this module: callers get a message and a signature.
|
||||||
|
//
|
||||||
|
// State file: { v: 1, iv, ct } — AES-256-GCM under HKDF(idRoot,
|
||||||
|
// "theseus-id/v1/state-key"), so it reads only with the vault open, and only
|
||||||
|
// on a vault with the same seed.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const nodeCrypto = require("node:crypto");
|
||||||
|
|
||||||
|
const LIST_TTL_MS = 60 * 60 * 1000; // origin lists: refresh after an hour
|
||||||
|
const LIST_STALE_MS = 7 * 24 * 3600 * 1000; // ...and use a cached copy up to a week if refresh fails
|
||||||
|
const SILENT_PER_MIN = 10; // prompt-free signatures per origin per minute
|
||||||
|
const MOVE_GIVE_UP_MS = 30 * 24 * 3600 * 1000;
|
||||||
|
const DEFAULT_TTL_S = 300;
|
||||||
|
|
||||||
|
const err = (code, message) => Object.assign(new Error(message || code), { code });
|
||||||
|
|
||||||
|
function createTheseusIdHost({
|
||||||
|
file,
|
||||||
|
loadLib, // async () => { lib, crypto } (TheseusID/lib + createCrypto(noble))
|
||||||
|
getPurposeRoot, // () => Uint8Array(32) | null (null = vault locked)
|
||||||
|
hasVault, // () => boolean
|
||||||
|
bundledRegistry, // the registry document shipped with Theseus (trusted as shipped)
|
||||||
|
fetchOriginDoc, // async (authority) => { doc, bns: boolean, owner?: string }
|
||||||
|
ui, // { unlock: async ({ reason }) => boolean, confirm: async (req) => { ok, always, mode } }
|
||||||
|
log = () => {},
|
||||||
|
now = () => Date.now(),
|
||||||
|
}) {
|
||||||
|
let libP = null;
|
||||||
|
const lib = () => (libP ||= loadLib().catch((e) => { libP = null; log("library failed to load:", e?.message || e); throw e; }));
|
||||||
|
let registry = null;
|
||||||
|
const listCache = new Map(); // authority -> { list, at, error }
|
||||||
|
const busy = new Set(); // origins with a request in flight
|
||||||
|
const silentLog = new Map(); // origin -> [timestamps]
|
||||||
|
let stateCache = null; // { rootHex, state }
|
||||||
|
|
||||||
|
async function getRegistry() {
|
||||||
|
if (registry) return registry;
|
||||||
|
const { lib: L } = await lib();
|
||||||
|
registry = L.verifyRegistry(bundledRegistry, { trusted: true });
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §3.3 / §3.4 — null when the project has no list we can trust.
|
||||||
|
async function originList(projectId) {
|
||||||
|
const { lib: L, crypto } = await lib();
|
||||||
|
const pid = L.parseProjectId(projectId);
|
||||||
|
if (!pid) throw err("bad-request", "bad project id");
|
||||||
|
if (pid.kind === "first-party") return (await getRegistry()).projects.get(projectId) || null;
|
||||||
|
const key = projectId;
|
||||||
|
const hit = listCache.get(key);
|
||||||
|
if (hit && hit.list && now() - hit.at < LIST_TTL_MS) return hit.list;
|
||||||
|
try {
|
||||||
|
const { doc, bns, owner } = await fetchOriginDoc(pid.authority);
|
||||||
|
const opts = { expectProject: projectId, sharedOrigins: (await getRegistry()).shared, now: now() };
|
||||||
|
const list = bns ? L.verifyOwnerSignedList(doc, { ...opts, crypto, owner }) : L.parseOriginList(doc, opts);
|
||||||
|
listCache.set(key, { list, at: now() });
|
||||||
|
return list;
|
||||||
|
} catch (e) {
|
||||||
|
log("origin list for", projectId, "failed:", e?.message || e);
|
||||||
|
if (hit && hit.list && now() - hit.at < LIST_STALE_MS) return hit.list;
|
||||||
|
throw err("origin-list-unavailable", `could not load ${projectId}'s origin list`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- encrypted state ----
|
||||||
|
async function keys() {
|
||||||
|
const pr = getPurposeRoot();
|
||||||
|
if (!pr) return null;
|
||||||
|
const { crypto } = await lib();
|
||||||
|
const idRoot = crypto.idRoot(pr);
|
||||||
|
return { idRoot, rootHex: Buffer.from(idRoot).toString("hex"), stateKey: crypto.stateKey(idRoot) };
|
||||||
|
}
|
||||||
|
const fresh = () => ({ v: 1, defaultMode: "project", autoFirstParty: true, projects: {} });
|
||||||
|
async function loadState() {
|
||||||
|
const k = await keys();
|
||||||
|
if (!k) throw err("locked", "the vault is locked");
|
||||||
|
if (stateCache && stateCache.rootHex === k.rootHex) return { k, state: stateCache.state };
|
||||||
|
let state = fresh();
|
||||||
|
try {
|
||||||
|
const rec = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
const d = nodeCrypto.createDecipheriv("aes-256-gcm", Buffer.from(k.stateKey), Buffer.from(rec.iv, "base64"));
|
||||||
|
const ct = Buffer.from(rec.ct, "base64");
|
||||||
|
d.setAuthTag(ct.subarray(ct.length - 16));
|
||||||
|
const pt = JSON.parse(Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8"));
|
||||||
|
if (pt && pt.v === 1 && pt.projects && typeof pt.projects === "object") state = { ...fresh(), ...pt };
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code !== "ENOENT") log("theseus-id state unreadable (other vault, or damaged); starting empty:", e.message);
|
||||||
|
}
|
||||||
|
stateCache = { rootHex: k.rootHex, state };
|
||||||
|
return { k, state };
|
||||||
|
}
|
||||||
|
async function saveState() {
|
||||||
|
const k = await keys();
|
||||||
|
if (!k || !stateCache || stateCache.rootHex !== k.rootHex) throw err("locked", "the vault is locked");
|
||||||
|
const iv = nodeCrypto.randomBytes(12);
|
||||||
|
const c = nodeCrypto.createCipheriv("aes-256-gcm", Buffer.from(k.stateKey), iv);
|
||||||
|
const ct = Buffer.concat([c.update(JSON.stringify(stateCache.state), "utf8"), c.final(), c.getAuthTag()]);
|
||||||
|
const tmp = file + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, iv: iv.toString("base64"), ct: ct.toString("base64") }), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
// Drop the decrypted copy when the vault locks.
|
||||||
|
function forget() { stateCache = null; }
|
||||||
|
|
||||||
|
async function accountFor(k, spec) {
|
||||||
|
const { crypto } = await lib();
|
||||||
|
const priv = crypto.key(k.idRoot, crypto.scope(spec));
|
||||||
|
try { return crypto.account(priv); } finally { priv.fill(0); }
|
||||||
|
}
|
||||||
|
async function signWith(k, spec, text) {
|
||||||
|
const { crypto } = await lib();
|
||||||
|
const priv = crypto.key(k.idRoot, crypto.scope(spec));
|
||||||
|
try { return crypto.sign(priv, text); } finally { priv.fill(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function silentAllowed(origin) {
|
||||||
|
const t = now();
|
||||||
|
const recent = (silentLog.get(origin) || []).filter((x) => t - x < 60_000);
|
||||||
|
silentLog.set(origin, recent);
|
||||||
|
return recent.length < SILENT_PER_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the page API (§5.1, §5.2) ----
|
||||||
|
// req: { projectId, nonce, statement?, requestId?, resources?, expiresIn?,
|
||||||
|
// origin, uri, gesture, ctx } origin/uri/gesture come from main, never the page;
|
||||||
|
// ctx is passed through to the prompts (main uses it for the tab).
|
||||||
|
async function signIn(req) {
|
||||||
|
const { lib: L } = await lib();
|
||||||
|
const origin = L.normOrigin(req.origin);
|
||||||
|
if (!origin) throw err("origin-not-listed", "this page cannot use Theseus ID");
|
||||||
|
if (typeof req.projectId !== "string" || typeof req.nonce !== "string") throw err("bad-request", "projectId and nonce are required");
|
||||||
|
if (busy.has(origin)) throw err("busy", "a sign-in is already waiting on this page");
|
||||||
|
busy.add(origin);
|
||||||
|
try {
|
||||||
|
const list = await originList(req.projectId);
|
||||||
|
if (!list || !L.originAllowed(list, origin)) throw err("origin-not-listed", `${origin} is not on ${req.projectId}'s origin list`);
|
||||||
|
if (!hasVault()) throw err("no-vault", "set up the Theseus Vault first");
|
||||||
|
if (!getPurposeRoot()) {
|
||||||
|
if (!req.gesture) throw err("locked", "the vault is locked");
|
||||||
|
const ok = await ui.unlock({ reason: `Sign in to ${list.name} with your Theseus ID.`, ctx: req.ctx });
|
||||||
|
if (!ok || !getPurposeRoot()) throw err("locked", "the vault stayed locked");
|
||||||
|
}
|
||||||
|
const { k, state } = await loadState();
|
||||||
|
let rec = state.projects[req.projectId] || null;
|
||||||
|
const firstParty = list.kind === "first-party";
|
||||||
|
const silent = !!rec && (rec.always || (firstParty && state.autoFirstParty)) && silentAllowed(origin);
|
||||||
|
let mode = rec ? rec.mode : state.defaultMode;
|
||||||
|
if (!silent) {
|
||||||
|
const preview = { mode: "project", gen: 0, projectId: req.projectId };
|
||||||
|
const accounts = {
|
||||||
|
project: await accountFor(k, preview),
|
||||||
|
one: await accountFor(k, { mode: "one", gen: 0 }),
|
||||||
|
};
|
||||||
|
const answer = await ui.confirm({
|
||||||
|
projectId: req.projectId, name: list.name, origin, firstParty, first: !rec, ctx: req.ctx,
|
||||||
|
mode, account: rec ? rec.account : accounts[mode], accounts,
|
||||||
|
});
|
||||||
|
if (!answer || !answer.ok) throw err("denied", "the user declined");
|
||||||
|
if (!rec && (answer.mode === "one" || answer.mode === "project")) mode = answer.mode;
|
||||||
|
if (!rec) {
|
||||||
|
rec = { mode, gen: 0, account: accounts[mode], firstSeen: new Date(now()).toISOString(), lastUsed: null, origins: [], always: false, move: null, wallets: [] };
|
||||||
|
state.projects[req.projectId] = rec;
|
||||||
|
}
|
||||||
|
if (answer.always) rec.always = true;
|
||||||
|
} else {
|
||||||
|
silentLog.get(origin).push(now());
|
||||||
|
}
|
||||||
|
// Which key signs: the current one, or the new one while a move is pending.
|
||||||
|
const cur = { mode: rec.mode, gen: rec.gen, projectId: req.projectId };
|
||||||
|
const curAccount = await accountFor(k, cur);
|
||||||
|
if (curAccount !== rec.account) throw err("state-mismatch", "this project's saved ID does not match the vault");
|
||||||
|
let signer = cur, account = rec.account, move;
|
||||||
|
const issuedAt = new Date(now()).toISOString();
|
||||||
|
if (rec.move) {
|
||||||
|
if (now() - Date.parse(rec.move.since) > MOVE_GIVE_UP_MS) {
|
||||||
|
finishMove(rec);
|
||||||
|
} else {
|
||||||
|
signer = { mode: rec.move.to.mode, gen: rec.move.to.gen, projectId: req.projectId };
|
||||||
|
account = rec.move.to.account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ttl = Math.min(600, Math.max(30, Number(req.expiresIn) || DEFAULT_TTL_S));
|
||||||
|
const message = L.buildMessage({
|
||||||
|
kind: "signin", origin, account, uri: String(req.uri || origin).slice(0, 2048), chainId: L.ID_CHAIN,
|
||||||
|
projectId: req.projectId, nonce: req.nonce, issuedAt, expirationTime: new Date(now() + ttl * 1000).toISOString(),
|
||||||
|
statement: req.statement || undefined, requestId: req.requestId || undefined,
|
||||||
|
resources: Array.isArray(req.resources) && req.resources.length ? req.resources : undefined,
|
||||||
|
});
|
||||||
|
const signature = await signWith(k, signer, message);
|
||||||
|
if (rec.move && account === rec.move.to.account) {
|
||||||
|
const text = L.buildMoveStatement({ projectId: req.projectId, from: rec.account, to: account, origin, nonce: req.nonce, issuedAt });
|
||||||
|
move = { from: rec.account, issuedAt, message: text, signatures: { old: await signWith(k, cur, text), new: await signWith(k, signer, text) } };
|
||||||
|
}
|
||||||
|
rec.lastUsed = issuedAt;
|
||||||
|
if (!rec.origins.includes(origin)) rec.origins = [...rec.origins, origin].slice(-16);
|
||||||
|
await saveState();
|
||||||
|
const out = { v: 1, projectId: req.projectId, origin, account, message, signature, scheme: "bip137" };
|
||||||
|
if (move) out.move = move;
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
busy.delete(origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMove(rec) {
|
||||||
|
rec.mode = rec.move.to.mode;
|
||||||
|
rec.gen = rec.move.to.gen;
|
||||||
|
rec.account = rec.move.to.account;
|
||||||
|
rec.move = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page tells Theseus its server accepted the move (§6.3 step 4).
|
||||||
|
async function moved({ projectId, origin, account }) {
|
||||||
|
const { lib: L } = await lib();
|
||||||
|
const list = await originList(projectId);
|
||||||
|
if (!list || !L.originAllowed(list, L.normOrigin(origin))) throw err("origin-not-listed");
|
||||||
|
const { state } = await loadState();
|
||||||
|
const rec = state.projects[projectId];
|
||||||
|
if (!rec || !rec.move || rec.move.to.account !== account) return { ok: false };
|
||||||
|
finishMove(rec);
|
||||||
|
await saveState();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings › Theseus ID (§5.5) ----
|
||||||
|
async function overview() {
|
||||||
|
if (!hasVault()) return { vault: "none" };
|
||||||
|
if (!getPurposeRoot()) return { vault: "locked" };
|
||||||
|
const { k, state } = await loadState();
|
||||||
|
const reg = await getRegistry();
|
||||||
|
const projects = [];
|
||||||
|
for (const [projectId, rec] of Object.entries(state.projects)) {
|
||||||
|
const list = reg.projects.get(projectId) || listCache.get(projectId)?.list || null;
|
||||||
|
projects.push({
|
||||||
|
projectId, name: list ? list.name : projectId, firstParty: !!(list && list.kind === "first-party"),
|
||||||
|
mode: rec.mode, gen: rec.gen, account: rec.account, firstSeen: rec.firstSeen, lastUsed: rec.lastUsed,
|
||||||
|
origins: rec.origins, always: !!rec.always, moving: rec.move ? { to: rec.move.to, since: rec.move.since } : null,
|
||||||
|
supportsMove: !!(list && list.supports.includes("move")), wallets: rec.wallets || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
projects.sort((a, b) => String(b.lastUsed || "").localeCompare(String(a.lastUsed || "")));
|
||||||
|
return {
|
||||||
|
vault: "unlocked", defaultMode: state.defaultMode, autoFirstParty: state.autoFirstParty,
|
||||||
|
oneId: await accountFor(k, { mode: "one", gen: 0 }), projects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function update(fn) {
|
||||||
|
const { k, state } = await loadState();
|
||||||
|
const r = await fn(state, k);
|
||||||
|
await saveState();
|
||||||
|
return r === undefined ? { ok: true } : r;
|
||||||
|
}
|
||||||
|
const setDefaultMode = (mode) => {
|
||||||
|
if (mode !== "one" && mode !== "project") throw err("bad-request", "mode");
|
||||||
|
return update((s) => { s.defaultMode = mode; }); // existing projects keep theirs (§6.3)
|
||||||
|
};
|
||||||
|
const setAutoFirstParty = (on) => update((s) => { s.autoFirstParty = !!on; });
|
||||||
|
const setAlways = (projectId, on) => update((s) => {
|
||||||
|
if (!s.projects[projectId]) throw err("unknown-project");
|
||||||
|
s.projects[projectId].always = !!on;
|
||||||
|
});
|
||||||
|
const revoke = (projectId) => update((s) => { delete s.projects[projectId]; });
|
||||||
|
// Change a project's mode or generation. Never silent: the next sign-in
|
||||||
|
// carries a move proof signed by both keys, and only projects that list
|
||||||
|
// "move" can take one (§6.3).
|
||||||
|
async function requestMove(projectId, { mode, gen }) {
|
||||||
|
const list = await originList(projectId);
|
||||||
|
return update(async (s, k) => {
|
||||||
|
const rec = s.projects[projectId];
|
||||||
|
if (!rec) throw err("unknown-project");
|
||||||
|
if (!list || !list.supports.includes("move")) throw err("move-unsupported", `${list ? list.name : projectId} cannot move accounts to a new ID yet`);
|
||||||
|
const to = { mode: mode || rec.mode, gen: Number.isInteger(gen) ? gen : rec.gen };
|
||||||
|
if (to.mode !== "one" && to.mode !== "project") throw err("bad-request", "mode");
|
||||||
|
to.account = await accountFor(k, { ...to, projectId });
|
||||||
|
if (to.account === rec.account) { rec.move = null; return { ok: true, unchanged: true }; }
|
||||||
|
rec.move = { to, since: new Date(now()).toISOString() };
|
||||||
|
return { ok: true, to };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cancelMove = (projectId) => update((s) => { if (s.projects[projectId]) s.projects[projectId].move = null; });
|
||||||
|
const rotate = (projectId) => update((s) => s.projects[projectId] || null).then((rec) => {
|
||||||
|
if (!rec) throw err("unknown-project");
|
||||||
|
return requestMove(projectId, { mode: rec.mode, gen: rec.gen + 1 });
|
||||||
|
});
|
||||||
|
async function recoveryKey() {
|
||||||
|
const k = await keys();
|
||||||
|
if (!k) throw err("locked");
|
||||||
|
return k.rootHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
signIn, moved, originList, overview, setDefaultMode, setAutoFirstParty, setAlways, revoke,
|
||||||
|
requestMove, cancelMove, rotate, recoveryKey, forget,
|
||||||
|
_test: { loadState, listCache },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createTheseusIdHost, SILENT_PER_MIN };
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
// useless on another machine or OS account.
|
// useless on another machine or OS account.
|
||||||
//
|
//
|
||||||
// The OS seal does not stop anything that runs as this OS user, nor a disk
|
// The OS seal does not stop anything that runs as this OS user, nor a disk
|
||||||
// image plus the Windows password; for those a 6-digit PIN falls to an
|
// image plus the Windows password; for those a 6-8 digit PIN falls to an
|
||||||
// offline search in minutes. Where a TPM is available the PIN is therefore
|
// offline search in minutes. Where a TPM is available the PIN is therefore
|
||||||
// also the authorization value of a TPM key (lib/tpm-pin.cjs) whose secret is
|
// also the authorization value of a TPM key (lib/tpm-pin.cjs) whose secret is
|
||||||
// mixed into the AES key, and the chip's own lockout limits guesses to about
|
// mixed into the AES key, and the chip's own lockout limits guesses to about
|
||||||
|
|
@ -29,7 +29,8 @@
|
||||||
// attacker on the machine.
|
// attacker on the machine.
|
||||||
//
|
//
|
||||||
// File: { v: 1, sealed: true, data: <b64 safeStorage blob>, fails, last }
|
// File: { v: 1, sealed: true, data: <b64 safeStorage blob>, fails, last }
|
||||||
// blob = { salt, iv, ct, iters, hw? } (all b64 except iters)
|
// blob = { salt, iv, ct, iters, len?, hw? } (all b64 except iters; len is
|
||||||
|
// the PIN's digit count, absent = 6 from builds that took only 6)
|
||||||
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
|
// hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
@ -41,7 +42,9 @@ const tpmPin = require("./tpm-pin.cjs");
|
||||||
const MAX_FAILS = 5;
|
const MAX_FAILS = 5;
|
||||||
const LOCKOUT_MS = 15 * 60 * 1000;
|
const LOCKOUT_MS = 15 * 60 * 1000;
|
||||||
const ITERATIONS = 600_000;
|
const ITERATIONS = 600_000;
|
||||||
const PIN_RE = /^\d{6}$/;
|
const PIN_MIN = 6;
|
||||||
|
const PIN_MAX = 8;
|
||||||
|
const PIN_RE = /^\d{6,8}$/;
|
||||||
|
|
||||||
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
|
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
|
||||||
const sealAvailable = () => {
|
const sealAvailable = () => {
|
||||||
|
|
@ -101,13 +104,16 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
|
||||||
lockedMs: lockedMsOf(rec),
|
lockedMs: lockedMsOf(rec),
|
||||||
sealed: !!(rec && rec.sealed),
|
sealed: !!(rec && rec.sealed),
|
||||||
hardware: b ? (b.hw ? "tpm" : "none") : null,
|
hardware: b ? (b.hw ? "tpm" : "none") : null,
|
||||||
|
// So PIN pads can draw the right number of dots and submit on the
|
||||||
|
// last digit. Knowing it saves an attacker under 12% of the search.
|
||||||
|
length: b ? b.len || PIN_MIN : null,
|
||||||
storable: sealAvailable(),
|
storable: sealAvailable(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Caller must have verified masterPassword against the vault first.
|
// Caller must have verified masterPassword against the vault first.
|
||||||
async set(pin, masterPassword) {
|
async set(pin, masterPassword) {
|
||||||
if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits");
|
if (!PIN_RE.test(String(pin || ""))) throw new Error(`the PIN must be ${PIN_MIN} to ${PIN_MAX} digits`);
|
||||||
if (!masterPassword) throw new Error("master password required");
|
if (!masterPassword) throw new Error("master password required");
|
||||||
if (!sealAvailable()) throw new Error("this system has no protected keystore, so a PIN cannot be stored safely");
|
if (!sealAvailable()) throw new Error("this system has no protected keystore, so a PIN cannot be stored safely");
|
||||||
const old = blobOrNull(read());
|
const old = blobOrNull(read());
|
||||||
|
|
@ -122,7 +128,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
|
||||||
if (hw) key = tpm.mixKey(hw.secret, key);
|
if (hw) key = tpm.mixKey(hw.secret, key);
|
||||||
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||||
const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]);
|
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 blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS, len: String(pin).length };
|
||||||
if (hw) blob.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
|
if (hw) blob.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
|
||||||
write({
|
write({
|
||||||
v: 1,
|
v: 1,
|
||||||
|
|
@ -148,6 +154,14 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
|
||||||
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
|
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
|
||||||
const locked = lockedMsOf(rec);
|
const locked = lockedMsOf(rec);
|
||||||
if (locked > 0) throw Object.assign(new Error("too many wrong PINs"), { code: "locked", remaining: 0, lockedMs: locked });
|
if (locked > 0) throw Object.assign(new Error("too many wrong PINs"), { code: "locked", remaining: 0, lockedMs: locked });
|
||||||
|
// A PIN of the wrong length cannot be right. Refuse it without a
|
||||||
|
// strike or a TPM attempt: an older Aegis pad that submits at six
|
||||||
|
// digits would otherwise burn the count against an 8-digit PIN.
|
||||||
|
const want = (blobOrNull(rec) || {}).len || PIN_MIN;
|
||||||
|
if (String(pin || "").length !== want) {
|
||||||
|
throw Object.assign(new Error(`your PIN has ${want} digits`),
|
||||||
|
{ code: "wrong-length", length: want, remaining: Math.max(0, MAX_FAILS - (rec.fails || 0)), lockedMs: 0 });
|
||||||
|
}
|
||||||
// Count the guess before trying it, so a crash mid-check still costs one.
|
// Count the guess before trying it, so a crash mid-check still costs one.
|
||||||
rec.fails = (rec.fails || 0) + 1;
|
rec.fails = (rec.fails || 0) + 1;
|
||||||
rec.last = now();
|
rec.last = now();
|
||||||
|
|
@ -205,4 +219,4 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS };
|
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS, PIN_MIN, PIN_MAX };
|
||||||
|
|
|
||||||
516
main.js
516
main.js
|
|
@ -491,6 +491,11 @@ const SETTINGS_DEFAULTS = {
|
||||||
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
|
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
|
||||||
clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession)
|
clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession)
|
||||||
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
|
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
|
||||||
|
keepSignInsOnQuit: true, // ...except for sites with a login saved in the vault (lib/signin-sites.cjs)
|
||||||
|
// Password manager offers (Settings › Passwords).
|
||||||
|
pwOfferSave: true, // ask to save a password after a login or sign-up form is sent
|
||||||
|
pwOfferFill: true, // offer saved logins when a login form appears
|
||||||
|
pwNeverSave: [], // hosts the user answered "Never" for
|
||||||
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
|
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
|
||||||
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
|
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
|
||||||
languageMode: "show", languageSpoof: "en-US", languageValue: "en-GB", // spoof = top-10 pick, manual = free text (English = UK original; US variant retired from the picker)
|
languageMode: "show", languageSpoof: "en-US", languageValue: "en-GB", // spoof = top-10 pick, manual = free text (English = UK original; US variant retired from the picker)
|
||||||
|
|
@ -897,9 +902,12 @@ const SETTINGS_ONLY = new Set([
|
||||||
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
|
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
|
||||||
"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", "sidebar-left-panels", "tor-state",
|
"settings-open-panel", "settings-section", "sidebar-left-panels",
|
||||||
|
"theseus-id-cancel-move", "theseus-id-move", "theseus-id-overview", "theseus-id-recovery-key", "theseus-id-revoke",
|
||||||
|
"theseus-id-rotate", "theseus-id-set-always", "theseus-id-set-auto", "theseus-id-set-default-mode", "theseus-id-unlock",
|
||||||
|
"tor-state",
|
||||||
"webapps-list", "webapps-open", "webapps-remove",
|
"webapps-list", "webapps-open", "webapps-remove",
|
||||||
"vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock",
|
"vault-check-master", "vault-confirm", "vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock",
|
||||||
// Raw seeds and WIFs. No page uses these (add-ons go through the
|
// Raw seeds and WIFs. No page uses these (add-ons go through the
|
||||||
// vaultImports shim in main), but an unguarded handler is reachable by any
|
// vaultImports shim in main), but an unguarded handler is reachable by any
|
||||||
// renderer that gets code execution.
|
// renderer that gets code execution.
|
||||||
|
|
@ -1220,9 +1228,35 @@ function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.w
|
||||||
// This wipes whichever the caller asked for. The `storages` list mirrors
|
// This wipes whichever the caller asked for. The `storages` list mirrors
|
||||||
// Chromium's clearStorageData taxonomy — we group them into a small user-
|
// Chromium's clearStorageData taxonomy — we group them into a small user-
|
||||||
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
|
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
|
||||||
async function clearBrowsingData({ cookies = false, cache = false, storage = false } = {}) {
|
async function clearBrowsingData({ cookies = false, cache = false, storage = false, keep = [] } = {}) {
|
||||||
const ses = session.defaultSession;
|
const ses = session.defaultSession;
|
||||||
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
|
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
|
||||||
|
// keep: origins whose sign-in survives (sites with a login in the vault).
|
||||||
|
// clearData can spare them; clearStorageData cannot. Falls through to the
|
||||||
|
// full clear if clearData refuses.
|
||||||
|
if (keep.length && (cookies || storage)) {
|
||||||
|
const dataTypes = [];
|
||||||
|
if (cookies) dataTypes.push("cookies");
|
||||||
|
if (storage) dataTypes.push("localStorage", "indexedDB", "serviceWorkers", "fileSystems", "webSQL");
|
||||||
|
// One origin Chromium refuses fails the whole call, so drop the one it
|
||||||
|
// names and try again rather than losing every kept sign-in.
|
||||||
|
let excludeOrigins = keep.slice();
|
||||||
|
for (let attempt = 0; attempt < 5 && excludeOrigins.length; attempt++) {
|
||||||
|
try {
|
||||||
|
await ses.clearData({ dataTypes, excludeOrigins });
|
||||||
|
if (storage) { try { await ses.clearStorageData({ storages: ["cachestorage", "shadercache"] }); } catch {} }
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("clearData (keeping sign-ins):", e.message);
|
||||||
|
const bad = /Invalid origin: '([^']+)'/.exec(e.message || "");
|
||||||
|
if (!bad) break;
|
||||||
|
const drop = bad[1].replace(/\/$/, "");
|
||||||
|
const before = excludeOrigins.length;
|
||||||
|
excludeOrigins = excludeOrigins.filter((o) => o !== drop);
|
||||||
|
if (excludeOrigins.length === before) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const storages = [];
|
const storages = [];
|
||||||
if (cookies) storages.push("cookies");
|
if (cookies) storages.push("cookies");
|
||||||
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
|
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
|
||||||
|
|
@ -1783,14 +1817,18 @@ function certFp(cert) {
|
||||||
const fp = cert && cert.fingerprint256;
|
const fp = cert && cert.fingerprint256;
|
||||||
return fp ? fp.toLowerCase().replace(/:/g, "") : "";
|
return fp ? fp.toLowerCase().replace(/:/g, "") : "";
|
||||||
}
|
}
|
||||||
async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
|
// init: { method, headers, body } for a page's own request (forms, fetch POSTs);
|
||||||
|
// a bare call is the GET it always was. Headers are already filtered by the
|
||||||
|
// caller (forwardHeaders); host and user-agent are always ours.
|
||||||
|
async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp, init = {}) {
|
||||||
const useTor = torState === "on";
|
const useTor = torState === "on";
|
||||||
if (useTor) await loadSocks();
|
if (useTor) await loadSocks();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const opts = {
|
const opts = {
|
||||||
host: ip, port, servername, method: "GET", path: reqPath,
|
host: ip, port, servername, method: init.method || "GET", path: reqPath,
|
||||||
headers: { host: servername, "user-agent": "theseus/1" },
|
headers: { ...(init.headers || {}), host: servername, "user-agent": "theseus/1" },
|
||||||
};
|
};
|
||||||
|
if (init.body) opts.headers["content-length"] = String(init.body.length);
|
||||||
opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
|
opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
|
||||||
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
|
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
|
||||||
const req = https.request(opts, (res) => {
|
const req = https.request(opts, (res) => {
|
||||||
|
|
@ -1801,39 +1839,68 @@ async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
|
||||||
}
|
}
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
res.on("data", (c) => chunks.push(c));
|
res.on("data", (c) => chunks.push(c));
|
||||||
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
|
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null,
|
||||||
|
setCookie: res.headers["set-cookie"] || null, buffer: Buffer.concat(chunks) }));
|
||||||
});
|
});
|
||||||
req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); });
|
req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); });
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.end();
|
req.end(init.body || undefined);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function httpGetByIp(ip, reqPath, hostHeader) {
|
async function httpGetByIp(ip, reqPath, hostHeader, init = {}) {
|
||||||
const useTor = torState === "on";
|
const useTor = torState === "on";
|
||||||
if (useTor) await loadSocks();
|
if (useTor) await loadSocks();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const opts = {
|
const opts = {
|
||||||
host: ip, port: 80, method: "GET", path: reqPath,
|
host: ip, port: 80, method: init.method || "GET", path: reqPath,
|
||||||
headers: { host: hostHeader, "user-agent": "theseus/1" },
|
headers: { ...(init.headers || {}), host: hostHeader, "user-agent": "theseus/1" },
|
||||||
};
|
};
|
||||||
|
if (init.body) opts.headers["content-length"] = String(init.body.length);
|
||||||
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
|
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
|
||||||
const req = http.request(opts, (res) => {
|
const req = http.request(opts, (res) => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
res.on("data", (c) => chunks.push(c));
|
res.on("data", (c) => chunks.push(c));
|
||||||
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) }));
|
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null,
|
||||||
|
setCookie: res.headers["set-cookie"] || null, buffer: Buffer.concat(chunks) }));
|
||||||
res.on("error", reject);
|
res.on("error", reject);
|
||||||
});
|
});
|
||||||
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
|
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
|
||||||
req.on("error", reject); req.end();
|
req.on("error", reject); req.end(init.body || undefined);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
|
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
|
||||||
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
|
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
|
||||||
// "not the site the chain says it is" and returning HTTP anyway would defeat
|
// "not the site the chain says it is" and returning HTTP anyway would defeat
|
||||||
// the pin.
|
// the pin.
|
||||||
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) {
|
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint, init = {}) {
|
||||||
if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase());
|
if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase(), init);
|
||||||
return await httpGetByIp(ip, reqPath, hostHeader);
|
return await httpGetByIp(ip, reqPath, hostHeader, init);
|
||||||
|
}
|
||||||
|
// What of a page's own request reaches its ip-record server: method, body
|
||||||
|
// and the headers a site needs to answer it (forms, JSON APIs, its own
|
||||||
|
// cookies). The page's bns:// origin is presented as https://<name>, the
|
||||||
|
// same mapping Theseus uses for that origin everywhere else (pageOriginOf),
|
||||||
|
// so a server can check Origin like any other site.
|
||||||
|
// x-*: a site's own custom request headers (CSRF tokens and the like).
|
||||||
|
const FORWARD_HEADERS = ["accept", "accept-language", "content-type", "authorization", "cookie", "origin", "if-none-match", "if-modified-since", "range"];
|
||||||
|
const FORWARD_HEADER_RE = /^x-[a-z0-9-]{1,40}$/;
|
||||||
|
async function forwardInit(request) {
|
||||||
|
const method = String(request.method || "GET").toUpperCase();
|
||||||
|
const headers = {};
|
||||||
|
for (const k of FORWARD_HEADERS) {
|
||||||
|
let v = request.headers.get(k);
|
||||||
|
if (v == null) continue;
|
||||||
|
if (k === "origin") v = v.replace(/^bns:\/\//i, "https://");
|
||||||
|
headers[k] = v;
|
||||||
|
}
|
||||||
|
for (const [k, v] of request.headers.entries()) if (FORWARD_HEADER_RE.test(k.toLowerCase())) headers[k.toLowerCase()] = v;
|
||||||
|
let body = null;
|
||||||
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
|
const buf = Buffer.from(await request.arrayBuffer());
|
||||||
|
if (buf.length > 8 * 1024 * 1024) throw new Error("request body too large");
|
||||||
|
body = buf;
|
||||||
|
}
|
||||||
|
return { method, headers, body };
|
||||||
}
|
}
|
||||||
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
|
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
|
||||||
// search template into our { name, url-with-%s } form.
|
// search template into our { name, url-with-%s } form.
|
||||||
|
|
@ -2065,8 +2132,10 @@ const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application
|
||||||
// upstream 204/304 into the 502 page.
|
// upstream 204/304 into the 502 page.
|
||||||
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
|
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
|
||||||
function upstreamResponse(up, contentType) {
|
function upstreamResponse(up, contentType) {
|
||||||
const headers = { "content-type": contentType };
|
const headers = new Headers({ "content-type": contentType });
|
||||||
if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location;
|
if (up.location && up.status >= 300 && up.status < 400) headers.set("location", up.location);
|
||||||
|
// The site's own cookies (sessions on ip-record sites) come back to its bns:// origin.
|
||||||
|
for (const c of up.setCookie || []) headers.append("set-cookie", c);
|
||||||
return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers });
|
return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2123,7 +2192,7 @@ async function serveBns(request) {
|
||||||
// record when available, HTTP fallback when not. Fixes serving BNS names
|
// record when available, HTTP fallback when not. Fixes serving BNS names
|
||||||
// whose server redirects :80→:443 (the plain-fetch path chokes on the
|
// whose server redirects :80→:443 (the plain-fetch path chokes on the
|
||||||
// redirect target because it isn't in ICANN DNS).
|
// redirect target because it isn't in ICANN DNS).
|
||||||
const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls);
|
const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls, await forwardInit(request));
|
||||||
return upstreamResponse(up, up.contentType || guessType(reqPath));
|
return upstreamResponse(up, up.contentType || guessType(reqPath));
|
||||||
};
|
};
|
||||||
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
|
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
|
||||||
|
|
@ -2759,8 +2828,9 @@ function initAddons() {
|
||||||
let pw;
|
let pw;
|
||||||
try { pw = await vaultPin().open(String(pin || "")); }
|
try { pw = await vaultPin().open(String(pin || "")); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0,
|
return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0, length: err.length,
|
||||||
error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message : undefined };
|
error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message
|
||||||
|
: err.code === "wrong-length" ? `Your PIN has ${err.length} digits. Use the master password, or update Aegis.` : undefined };
|
||||||
}
|
}
|
||||||
try { await unlockVaultWithMaster(pw); }
|
try { await unlockVaultWithMaster(pw); }
|
||||||
catch {
|
catch {
|
||||||
|
|
@ -3556,25 +3626,38 @@ function setSidebar(show, panelId) {
|
||||||
emitSidebarState();
|
emitSidebarState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showPwFill(show, matches) {
|
function showPwFill(show, matches, extra = {}) {
|
||||||
if (!pwFillPop) return;
|
if (!pwFillPop) return;
|
||||||
if (show) {
|
if (show) {
|
||||||
if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches))) return;
|
if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches, extra))) return;
|
||||||
positionPwFill();
|
positionPwFill();
|
||||||
win.contentView.removeChildView(pwFillPop);
|
win.contentView.removeChildView(pwFillPop);
|
||||||
win.contentView.addChildView(pwFillPop);
|
win.contentView.addChildView(pwFillPop);
|
||||||
pwFillPop.setVisible(true); pwfVisible = true;
|
pwFillPop.setVisible(true); pwfVisible = true;
|
||||||
pwFillPop.webContents.send("pw-matches", { matches: matches || [] });
|
pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "" });
|
||||||
} else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
|
} else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
|
||||||
}
|
}
|
||||||
// Compute credential matches for a host. Exact hostname match in phase-1;
|
// Compute credential matches for a host. Exact hostname match in phase-1;
|
||||||
// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot).
|
// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot).
|
||||||
|
// Hostnames with a saved login, kept outside the vault (sealed) so the quit
|
||||||
|
// clear can spare their sign-ins while the vault is locked. Rewritten only
|
||||||
|
// when the vault is open and the set changed.
|
||||||
|
const { createSigninSites, normHost: normSigninHost } = require("./lib/signin-sites.cjs");
|
||||||
|
let signinSitesInst = null;
|
||||||
|
const signinSites = () => (signinSitesInst ||= createSigninSites({ file: path.join(app.getPath("userData"), "signin-sites.json"), safeStorage, log: (...a) => console.log("[signin-sites]", ...a) }));
|
||||||
|
function refreshSigninSites() {
|
||||||
|
if (!vaultState) return;
|
||||||
|
const want = [...new Set((vaultState.entries || []).map((e) => normSigninHost(e.domain)).filter(Boolean))].sort();
|
||||||
|
const have = signinSites().load();
|
||||||
|
if (want.length === have.length && want.every((h, i) => h === have[i])) return;
|
||||||
|
signinSites().save(want);
|
||||||
|
}
|
||||||
function pwMatchesForHost(host) {
|
function pwMatchesForHost(host) {
|
||||||
if (!vaultState || !host) return [];
|
if (!vaultState || !host) return [];
|
||||||
const h = String(host).toLowerCase();
|
const h = String(host).toLowerCase();
|
||||||
return (vaultState.entries || [])
|
return (vaultState.entries || [])
|
||||||
.filter((e) => e.domain === h)
|
.filter((e) => e.domain === h)
|
||||||
.map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" }));
|
.map((e) => ({ id: e.id, domain: e.domain, username: e.username || "", confirm: !!e.confirm }));
|
||||||
}
|
}
|
||||||
// The host of what the tab is showing right now. t.prov.host is set by our own
|
// The host of what the tab is showing right now. t.prov.host is set by our own
|
||||||
// navigations only, so it goes stale on Back/Forward and server redirects —
|
// navigations only, so it goes stale on Back/Forward and server redirects —
|
||||||
|
|
@ -3589,6 +3672,8 @@ function liveHost(t) {
|
||||||
// Emit the current tab's match count to chrome so the toolbar chip can
|
// Emit the current tab's match count to chrome so the toolbar chip can
|
||||||
// show/hide + display the count. Cheap; called on nav + vault unlock/lock.
|
// show/hide + display the count. Cheap; called on nav + vault unlock/lock.
|
||||||
function emitPwAvailability() {
|
function emitPwAvailability() {
|
||||||
|
refreshSigninSites();
|
||||||
|
if (!vaultState && theseusIdInst) theseusIdInst.forget(); // drop the decrypted Theseus ID record on lock
|
||||||
const t = activeTab();
|
const t = activeTab();
|
||||||
const host = t ? liveHost(t) : "";
|
const host = t ? liveHost(t) : "";
|
||||||
const count = pwMatchesForHost(host).length;
|
const count = pwMatchesForHost(host).length;
|
||||||
|
|
@ -3837,6 +3922,7 @@ function setActive(id) {
|
||||||
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
|
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
|
||||||
if (epVisible) showEnginePicker(false);
|
if (epVisible) showEnginePicker(false);
|
||||||
if (lpVisible) showLangPicker(false);
|
if (lpVisible) showLangPicker(false);
|
||||||
|
if (pwfVisible && switching) showPwFill(false); // the login offer belongs to the tab left behind
|
||||||
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
|
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
|
||||||
// A user action that switches to a different tab (New Tab, Settings,
|
// A user action that switches to a different tab (New Tab, Settings,
|
||||||
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
|
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
|
||||||
|
|
@ -6481,7 +6567,9 @@ function showApprovalModal(opts, addonId, tabId = null) {
|
||||||
const req = {
|
const req = {
|
||||||
reqId: ++approvalSeq,
|
reqId: ++approvalSeq,
|
||||||
addonId,
|
addonId,
|
||||||
addonName: a ? a.manifest.name : addonId,
|
// addonId null = Theseus itself asking (the password manager, Theseus ID).
|
||||||
|
addonName: a ? a.manifest.name : addonId || String(opts.from || "Theseus"),
|
||||||
|
builtin: !addonId,
|
||||||
title: String(opts.title || "Approve?"),
|
title: String(opts.title || "Approve?"),
|
||||||
body: opts.body == null ? "" : String(opts.body),
|
body: opts.body == null ? "" : String(opts.body),
|
||||||
origin: opts.origin == null ? "" : String(opts.origin),
|
origin: opts.origin == null ? "" : String(opts.origin),
|
||||||
|
|
@ -7347,21 +7435,23 @@ let unlockCurrent = null;
|
||||||
let unlockSeq = 0;
|
let unlockSeq = 0;
|
||||||
// Resolves { ok: true } once the vault is unlocked, { ok: false, reason }
|
// Resolves { ok: true } once the vault is unlocked, { ok: false, reason }
|
||||||
// when there is no vault or the user cancels.
|
// when there is no vault or the user cancels.
|
||||||
function requestVaultUnlock({ reason, addonId } = {}) {
|
// confirm: ask for the PIN or master password even when the vault is already
|
||||||
if (vaultState) return Promise.resolve({ ok: true, already: true });
|
// open — the check a saved login can require before it is filled.
|
||||||
|
function requestVaultUnlock({ reason, addonId, confirm = false } = {}) {
|
||||||
|
if (vaultState && !confirm) return Promise.resolve({ ok: true, already: true });
|
||||||
if (!fs.existsSync(vaultFile())) return Promise.resolve({ ok: false, reason: "no-vault" });
|
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 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) };
|
const req = { reqId: ++unlockSeq, addonName: a ? a.manifest.name : "Theseus", reason: String(reason || "").slice(0, 200), confirm: !!confirm };
|
||||||
return new Promise((resolve) => { unlockQueue.push({ req, resolve }); pumpUnlock(); });
|
return new Promise((resolve) => { unlockQueue.push({ req, resolve }); pumpUnlock(); });
|
||||||
}
|
}
|
||||||
function pumpUnlock() {
|
function pumpUnlock() {
|
||||||
if (unlockCurrent || !unlockQueue.length || !unlockPop) return;
|
if (unlockCurrent || !unlockQueue.length || !unlockPop) return;
|
||||||
const next = unlockQueue.shift();
|
const next = unlockQueue.shift();
|
||||||
if (vaultState) { next.resolve({ ok: true, already: true }); pumpUnlock(); return; }
|
if (vaultState && !next.req.confirm) { next.resolve({ ok: true, already: true }); pumpUnlock(); return; }
|
||||||
unlockCurrent = next;
|
unlockCurrent = next;
|
||||||
const st = vaultPin().status();
|
const st = vaultPin().status();
|
||||||
overlayReady(unlockPop).then(() => {
|
overlayReady(unlockPop).then(() => {
|
||||||
unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS });
|
unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, pinLength: st.length || 6, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS });
|
||||||
try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
|
try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
|
||||||
unlockPop.setVisible(true);
|
unlockPop.setVisible(true);
|
||||||
unlockPop.webContents.focus();
|
unlockPop.webContents.focus();
|
||||||
|
|
@ -7389,12 +7479,21 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
|
||||||
catch (err) {
|
catch (err) {
|
||||||
// Same words as Aegis's PIN pads: one PIN, one policy, one wording.
|
// 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 === "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 === "wrong-length") return { ok: false, mode: "pin", pinLength: err.length, error: `Your PIN has ${err.length} digits.` };
|
||||||
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.` };
|
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 };
|
return { ok: false, mode: "password", error: err.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await unlockVaultWithMaster(masterPassword);
|
if (unlockCurrent.req.confirm && vaultState) {
|
||||||
|
// Already open: only prove the password, without re-reading the vault
|
||||||
|
// over the live state.
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
await v.unlockVault(vaultFile(), masterPassword);
|
||||||
|
try { vaultPin().resetFails(); } catch {}
|
||||||
|
} else {
|
||||||
|
await unlockVaultWithMaster(masterPassword);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (mode === "pin") {
|
if (mode === "pin") {
|
||||||
// The PIN opened, but its master password no longer opens the vault
|
// The PIN opened, but its master password no longer opens the vault
|
||||||
|
|
@ -7424,7 +7523,18 @@ ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => {
|
||||||
catch (e) { return vaultErr(e.message); }
|
catch (e) { return vaultErr(e.message); }
|
||||||
});
|
});
|
||||||
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); });
|
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); });
|
||||||
|
// Step 1 of the PIN setup dialog: prove the master password before the user
|
||||||
|
// picks a PIN. Opens the vault file only to test it; vault-pin-set checks again.
|
||||||
|
ipcMain.handle("vault-check-master", async (_e, masterPassword) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
await v.unlockVault(vaultFile(), String(masterPassword || ""));
|
||||||
|
return vaultOk();
|
||||||
|
} catch { await new Promise((r) => setTimeout(r, 600)); return vaultErr("wrong master password"); }
|
||||||
|
});
|
||||||
ipcMain.handle("vault-pin-unlock", () => requestVaultUnlock({ reason: "Open your saved passwords." }));
|
ipcMain.handle("vault-pin-unlock", () => requestVaultUnlock({ reason: "Open your saved passwords." }));
|
||||||
|
ipcMain.handle("vault-confirm", (_e, reason) => requestVaultUnlock({ confirm: true, reason: String(reason || "").slice(0, 200) }));
|
||||||
|
|
||||||
ipcMain.handle("password-status", () => ({
|
ipcMain.handle("password-status", () => ({
|
||||||
setup: fs.existsSync(vaultFile()),
|
setup: fs.existsSync(vaultFile()),
|
||||||
|
|
@ -7497,6 +7607,11 @@ ipcMain.handle("password-get", async (_e, id) => {
|
||||||
if (!vaultState) return vaultErr("locked");
|
if (!vaultState) return vaultErr("locked");
|
||||||
try {
|
try {
|
||||||
const v = await loadVaultLib();
|
const v = await loadVaultLib();
|
||||||
|
const entry = vaultState.entries.find((x) => x.id === id);
|
||||||
|
if (entry && entry.confirm) {
|
||||||
|
const c = await requestVaultUnlock({ confirm: true, reason: `Confirm it's you to show your ${entry.domain} password.` });
|
||||||
|
if (!c.ok || !vaultState) return vaultErr("cancelled");
|
||||||
|
}
|
||||||
const password = await v.resolvePassword(vaultState, id);
|
const password = await v.resolvePassword(vaultState, id);
|
||||||
return { ok: true, password };
|
return { ok: true, password };
|
||||||
} catch (e) { return vaultErr(e?.message || e); }
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
|
@ -7509,6 +7624,7 @@ ipcMain.handle("password-add", async (_e, spec) => {
|
||||||
const entry = v.newEntry(spec || {});
|
const entry = v.newEntry(spec || {});
|
||||||
vaultState.entries.push(entry);
|
vaultState.entries.push(entry);
|
||||||
await v.saveVault(vaultFile(), vaultState);
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
emitPwAvailability();
|
||||||
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
|
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
|
||||||
} catch (e) { return vaultErr(e?.message || e); }
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
});
|
});
|
||||||
|
|
@ -7521,10 +7637,13 @@ ipcMain.handle("password-update", async (_e, id, patch) => {
|
||||||
if (!e) return vaultErr("no such entry");
|
if (!e) return vaultErr("no such entry");
|
||||||
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
|
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
|
||||||
for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k];
|
for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k];
|
||||||
|
// Ask for the PIN or password before this login is filled.
|
||||||
|
if (patch && "confirm" in patch) { if (patch.confirm) e.confirm = true; else delete e.confirm; }
|
||||||
// Switching between literal and generated: drop the other field.
|
// Switching between literal and generated: drop the other field.
|
||||||
if (patch && "literal" in patch) delete e.generated;
|
if (patch && "literal" in patch) delete e.generated;
|
||||||
if (patch && "generated" in patch) delete e.literal;
|
if (patch && "generated" in patch) delete e.literal;
|
||||||
await v.saveVault(vaultFile(), vaultState);
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
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); }
|
||||||
});
|
});
|
||||||
|
|
@ -7535,6 +7654,7 @@ ipcMain.handle("password-remove", async (_e, id) => {
|
||||||
const v = await loadVaultLib();
|
const v = await loadVaultLib();
|
||||||
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
|
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
|
||||||
await v.saveVault(vaultFile(), vaultState);
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
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); }
|
||||||
});
|
});
|
||||||
|
|
@ -7800,15 +7920,340 @@ ipcMain.handle("pw-fill-resize", (_e, h) => {
|
||||||
ipcMain.handle("pw-fill-pick", async (e, id) => {
|
ipcMain.handle("pw-fill-pick", async (e, id) => {
|
||||||
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
|
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
|
||||||
showPwFill(false);
|
showPwFill(false);
|
||||||
|
const t = activeTab();
|
||||||
|
const host = t ? liveHost(t) : "";
|
||||||
|
let justUnlocked = false; // a PIN / password typed for this very fill also answers the per-login check
|
||||||
|
if (id === "__unlock") {
|
||||||
|
// Offered on a locked vault: unlock, then fill at once when there is one
|
||||||
|
// login for the site, or show the choice again.
|
||||||
|
const u = await requestVaultUnlock({ reason: host ? `Sign in to ${host} with a saved login.` : "" });
|
||||||
|
if (!u.ok) return { ok: false, err: u.reason || "cancelled" };
|
||||||
|
justUnlocked = !u.already;
|
||||||
|
const matches = pwMatchesForHost(host);
|
||||||
|
if (matches.length === 1) id = matches[0].id;
|
||||||
|
else { if (matches.length) showPwFill(true, matches, { host }); return { ok: true, shown: matches.length }; }
|
||||||
|
}
|
||||||
if (!vaultState) return { ok: false, err: "locked" };
|
if (!vaultState) return { ok: false, err: "locked" };
|
||||||
try {
|
try {
|
||||||
const v = await loadVaultLib();
|
const v = await loadVaultLib();
|
||||||
const entry = vaultState.entries.find((x) => x.id === id);
|
const entry = vaultState.entries.find((x) => x.id === id);
|
||||||
if (!entry) return { ok: false, err: "no such entry" };
|
if (!entry) return { ok: false, err: "no such entry" };
|
||||||
|
if (entry.confirm && !justUnlocked) {
|
||||||
|
// The user asked for a check before this login is filled.
|
||||||
|
const c = await requestVaultUnlock({ confirm: true, reason: `Confirm it's you to fill your ${entry.domain} login.` });
|
||||||
|
if (!c.ok) return { ok: false, err: "cancelled" };
|
||||||
|
}
|
||||||
const password = await v.resolvePassword(vaultState, id);
|
const password = await v.resolvePassword(vaultState, id);
|
||||||
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password });
|
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password });
|
||||||
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
|
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Password manager: offer saved logins, offer to save new ones ----------
|
||||||
|
// home-preload.js runs in the top frame of every web tab, in its isolated
|
||||||
|
// world, and reports two things: a login field got focus ("pw-form"), and a
|
||||||
|
// form carrying a password was sent ("pw-capture"). It exposes nothing to
|
||||||
|
// the page. The host is always taken from the tab's committed URL, never
|
||||||
|
// from the message.
|
||||||
|
function webTabForEvent(e) {
|
||||||
|
const t = tabForSender(e.sender);
|
||||||
|
if (!t || t.settings || t.addonId) return null;
|
||||||
|
if (e.senderFrame !== e.sender.mainFrame) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
let pwfOfferTab = null;
|
||||||
|
const pwfNavHooked = new WeakSet();
|
||||||
|
ipcMain.on("pw-form", (e, rect) => {
|
||||||
|
try {
|
||||||
|
if (!settings.pwOfferFill) return;
|
||||||
|
const t = webTabForEvent(e);
|
||||||
|
if (!t || t.id !== activeId) return;
|
||||||
|
const host = liveHost(t);
|
||||||
|
if (!host || !fs.existsSync(vaultFile())) return;
|
||||||
|
let matches = [], locked = false;
|
||||||
|
if (vaultState) {
|
||||||
|
matches = pwMatchesForHost(host);
|
||||||
|
if (!matches.length) return;
|
||||||
|
} else {
|
||||||
|
// Locked: the sealed list of sites with a login says whether there is
|
||||||
|
// anything to offer, without opening the vault.
|
||||||
|
if (!signinSites().load().includes(host)) return;
|
||||||
|
locked = true;
|
||||||
|
}
|
||||||
|
const b = t.view.getBounds();
|
||||||
|
const z = t.view.webContents.getZoomFactor() || 1;
|
||||||
|
const r = rect && typeof rect === "object" ? rect : {};
|
||||||
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
||||||
|
const x = Math.round(b.x + num(r.x) * z);
|
||||||
|
const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4);
|
||||||
|
pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) };
|
||||||
|
pwfOfferTab = t.id;
|
||||||
|
const wc = t.view.webContents;
|
||||||
|
if (!pwfNavHooked.has(wc)) {
|
||||||
|
pwfNavHooked.add(wc);
|
||||||
|
wc.on("did-start-navigation", (_ev, _url, inPage, isMain) => {
|
||||||
|
if (isMain && !inPage && pwfVisible && pwfOfferTab === t.id) showPwFill(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showPwFill(true, matches, { locked, host });
|
||||||
|
} catch (err) { console.warn("pw-form:", err?.message); }
|
||||||
|
});
|
||||||
|
ipcMain.on("pw-form-dismiss", (e) => {
|
||||||
|
const t = webTabForEvent(e);
|
||||||
|
if (t && pwfVisible && pwfOfferTab === t.id) showPwFill(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pwCaptureSeen = new Map(); // tabId -> { key, at }: one offer per login, however many events report it
|
||||||
|
ipcMain.on("pw-capture", (e, data) => {
|
||||||
|
try {
|
||||||
|
if (!settings.pwOfferSave) return;
|
||||||
|
const t = webTabForEvent(e);
|
||||||
|
if (!t) return;
|
||||||
|
const host = liveHost(t);
|
||||||
|
if (!host || (settings.pwNeverSave || []).includes(host)) return;
|
||||||
|
const username = String((data && data.username) || "").trim().slice(0, 256);
|
||||||
|
const password = String((data && data.password) || "");
|
||||||
|
if (!password || password.length > 1024) return;
|
||||||
|
const key = require("node:crypto").createHash("sha256").update(host + "\n" + username + "\n" + password).digest("hex");
|
||||||
|
const seen = pwCaptureSeen.get(t.id);
|
||||||
|
if (seen && seen.key === key && Date.now() - seen.at < 60_000) return;
|
||||||
|
pwCaptureSeen.set(t.id, { key, at: Date.now() });
|
||||||
|
// A beat later, so a login that navigates away shows the offer on the
|
||||||
|
// page it lands on rather than flashing over the form.
|
||||||
|
setTimeout(() => offerSavePassword(t.id, host, username, password).catch((err) => console.warn("pw save offer:", err?.message)), 900);
|
||||||
|
} catch (err) { console.warn("pw-capture:", err?.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// True once the login looks done: the page moved to another site, or no
|
||||||
|
// password field is left on it. A password field still showing after a few
|
||||||
|
// seconds (filled, or emptied by a "wrong password" page) means the login
|
||||||
|
// did not go through, and a wrong password is not worth saving.
|
||||||
|
async function pwLoginSettled(tabId, host) {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const t = tabs.find((x) => x.id === tabId);
|
||||||
|
if (!t) return false;
|
||||||
|
if (liveHost(t) !== host) return true;
|
||||||
|
let pwField = false;
|
||||||
|
try {
|
||||||
|
pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{
|
||||||
|
code: "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)",
|
||||||
|
}]);
|
||||||
|
} catch { return true; } // navigating: the page is going away
|
||||||
|
if (!pwField) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async function offerSavePassword(tabId, host, username, password) {
|
||||||
|
if (!tabs.some((x) => x.id === tabId)) return;
|
||||||
|
if (!(await pwLoginSettled(tabId, host))) return;
|
||||||
|
if (!fs.existsSync(vaultFile())) {
|
||||||
|
const pick = await showApprovalModal({
|
||||||
|
from: "Theseus Vault",
|
||||||
|
title: "Save your passwords in Theseus?",
|
||||||
|
body: "Theseus can remember this login and fill it next time. Set up the Theseus Vault first: it is encrypted with a master password only you know, and nothing leaves this computer.",
|
||||||
|
origin: host,
|
||||||
|
actions: [{ id: "setup", label: "Set up the vault", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }],
|
||||||
|
}, null, tabId);
|
||||||
|
if (pick === "setup") openSettingsTab("passwords");
|
||||||
|
else if (pick === "never") pwNeverFor(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Already saved, unchanged: nothing to ask. (Only knowable while open.)
|
||||||
|
let update = false;
|
||||||
|
if (vaultState) {
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
const same = (vaultState.entries || []).find((x) => x.domain === host && (x.username || "") === username);
|
||||||
|
if (same) {
|
||||||
|
if ((await v.resolvePassword(vaultState, same.id).catch(() => null)) === password) return;
|
||||||
|
update = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pick = await showApprovalModal({
|
||||||
|
from: "Theseus Vault",
|
||||||
|
title: update ? "Update the saved password?" : "Save this password?",
|
||||||
|
origin: host,
|
||||||
|
rows: [
|
||||||
|
{ label: "Username", value: username || "(none)" },
|
||||||
|
{ label: "Password", value: "•".repeat(Math.min(12, password.length)) },
|
||||||
|
],
|
||||||
|
checkbox: { id: "confirm", label: "Ask for my PIN or password before filling it" },
|
||||||
|
actions: update
|
||||||
|
? [{ id: "save", label: "Update", primary: true }, { id: "cancel", label: "Not now" }]
|
||||||
|
: [{ id: "save", label: "Save", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }],
|
||||||
|
}, null, tabId);
|
||||||
|
if (pick === "never") return pwNeverFor(host);
|
||||||
|
if (!pick.startsWith("save")) return;
|
||||||
|
const confirm = pick.split("+").includes("confirm");
|
||||||
|
const u = await requestVaultUnlock({ reason: `Save your ${host} login in the vault.` });
|
||||||
|
if (!u.ok || !vaultState) return;
|
||||||
|
const v = await loadVaultLib();
|
||||||
|
const same = vaultState.entries.find((x) => x.domain === host && (x.username || "") === username);
|
||||||
|
if (same) {
|
||||||
|
same.literal = password;
|
||||||
|
delete same.generated;
|
||||||
|
if (confirm) same.confirm = true; else delete same.confirm;
|
||||||
|
} else {
|
||||||
|
const entry = v.newEntry({ domain: host, username, literal: password });
|
||||||
|
if (confirm) entry.confirm = true;
|
||||||
|
vaultState.entries.push(entry);
|
||||||
|
}
|
||||||
|
await v.saveVault(vaultFile(), vaultState);
|
||||||
|
emitPwAvailability();
|
||||||
|
}
|
||||||
|
function pwNeverFor(host) {
|
||||||
|
const list = Array.isArray(settings.pwNeverSave) ? settings.pwNeverSave : [];
|
||||||
|
if (!list.includes(host)) { settings.pwNeverSave = [...list, host].slice(-500); saveSettings(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Theseus ID (DESIGN-theseus-id.md) --------------------------------------
|
||||||
|
// window.theseusId.signIn() on web pages (theseus-id-preload.js) and
|
||||||
|
// Settings › Theseus ID. The policy and the encrypted per-project record
|
||||||
|
// live in lib/theseus-id.cjs; the derivation, message and origin-list rules
|
||||||
|
// in TheseusID/lib (copied to resources/theseus-id/ in a packaged build).
|
||||||
|
const THESEUS_ID_LIB = app.isPackaged
|
||||||
|
? path.join(RES_DIR, "theseus-id", "index.mjs")
|
||||||
|
: path.join(__dirname, "..", "TheseusID", "lib", "index.mjs");
|
||||||
|
const THESEUS_ID_REGISTRY = app.isPackaged
|
||||||
|
? path.join(RES_DIR, "theseus-id-projects.json")
|
||||||
|
: path.join(__dirname, "..", "TheseusID", "registry", "projects.json");
|
||||||
|
let theseusIdInst = null;
|
||||||
|
function theseusId() {
|
||||||
|
if (theseusIdInst) return theseusIdInst;
|
||||||
|
const { createTheseusIdHost } = require("./lib/theseus-id.cjs");
|
||||||
|
const registry = JSON.parse(fs.readFileSync(THESEUS_ID_REGISTRY, "utf8"));
|
||||||
|
// Development only: extra first-party test projects (e.g. a local page).
|
||||||
|
if (!app.isPackaged && process.env.THESEUS_ID_DEV_REGISTRY) {
|
||||||
|
try { registry.projects.push(...JSON.parse(fs.readFileSync(process.env.THESEUS_ID_DEV_REGISTRY, "utf8")).projects); }
|
||||||
|
catch (e) { console.warn("[theseus-id] dev registry:", e.message); }
|
||||||
|
}
|
||||||
|
theseusIdInst = createTheseusIdHost({
|
||||||
|
file: path.join(app.getPath("userData"), "theseus-id.json"),
|
||||||
|
loadLib: async () => {
|
||||||
|
// ESM-only deps: resolve from the app tree, then import the file URL
|
||||||
|
// (the same way addons-host's hostImport does).
|
||||||
|
const { pathToFileURL } = require("node:url");
|
||||||
|
const imp = (name) => import(pathToFileURL(require.resolve(name)).href);
|
||||||
|
const t0 = Date.now();
|
||||||
|
const lib = await import(pathToFileURL(THESEUS_ID_LIB).href);
|
||||||
|
const [{ secp256k1 }, { sha256 }, { ripemd160 }, { hkdf }] = await Promise.all([
|
||||||
|
imp("@noble/curves/secp256k1.js"), imp("@noble/hashes/sha2.js"), imp("@noble/hashes/legacy.js"), imp("@noble/hashes/hkdf.js"),
|
||||||
|
]);
|
||||||
|
console.log(`[theseus-id] library loaded in ${Date.now() - t0} ms`);
|
||||||
|
return { lib, crypto: lib.createCrypto({ secp256k1, sha256, ripemd160, hkdf }) };
|
||||||
|
},
|
||||||
|
getPurposeRoot: () => (vaultState && vaultState.purposeRoot ? new Uint8Array(Buffer.from(vaultState.purposeRoot, "hex")) : null),
|
||||||
|
hasVault: () => fs.existsSync(vaultFile()),
|
||||||
|
bundledRegistry: registry,
|
||||||
|
fetchOriginDoc: theseusIdFetchOriginDoc,
|
||||||
|
ui: {
|
||||||
|
unlock: async ({ reason }) => (await requestVaultUnlock({ reason })).ok,
|
||||||
|
confirm: theseusIdConfirm,
|
||||||
|
},
|
||||||
|
log: (...a) => console.log("[theseus-id]", ...a),
|
||||||
|
});
|
||||||
|
return theseusIdInst;
|
||||||
|
}
|
||||||
|
// A name-scoped project's origin list: BNS first (owner-signed, owner from
|
||||||
|
// our own index), the web over TLS otherwise (§3.4).
|
||||||
|
async function theseusIdFetchOriginDoc(authority) {
|
||||||
|
const getJson = async (url) => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), 10_000);
|
||||||
|
try {
|
||||||
|
const r = await contentFetch(url, { signal: ctl.signal, redirect: "error", headers: { accept: "application/json" } });
|
||||||
|
if (r.status < 200 || r.status >= 300) throw new Error(`HTTP ${r.status}`);
|
||||||
|
if (r.buffer.length > 64 * 1024) throw new Error("origin list too large");
|
||||||
|
return JSON.parse(r.buffer.toString("utf8"));
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
};
|
||||||
|
const entry = isBnsHost(authority) ? await resolveHost(authority).catch(() => null) : null;
|
||||||
|
if (entry) {
|
||||||
|
if (!entry.owner) throw new Error(`no owner known for ${authority}`);
|
||||||
|
return { doc: await getJson(`${GATEWAY}/bns/${authority}/.well-known/theseus-id.json`), bns: true, owner: entry.owner };
|
||||||
|
}
|
||||||
|
return { doc: await getJson(`https://${authority}/.well-known/theseus-id.json`), bns: false };
|
||||||
|
}
|
||||||
|
const tidShort = (a) => (a && a.length > 20 ? `${a.slice(0, 12)}…${a.slice(-4)}` : a);
|
||||||
|
async function theseusIdConfirm(r) {
|
||||||
|
const modeLabel = (m) => (m === "one" ? `${tidShort(r.accounts ? r.accounts.one : r.account)} — your One ID` : `${tidShort(r.accounts ? r.accounts.project : r.account)} — an ID only ${r.name} sees`);
|
||||||
|
const opts = {
|
||||||
|
from: "Theseus ID",
|
||||||
|
title: `Sign in to ${r.name}?`,
|
||||||
|
origin: r.origin,
|
||||||
|
body: r.first
|
||||||
|
? `A Theseus ID is your Silent Mode account, kept in your Theseus Vault. ${r.name} gets a signature from it, never a key, and can't see your wallets.`
|
||||||
|
: "",
|
||||||
|
rows: [
|
||||||
|
...(r.first ? [] : [{ label: "As", value: modeLabel(r.mode), mono: true }]),
|
||||||
|
{ label: "Project", value: `${r.projectId}${r.firstParty ? " · Silent Mode" : ""} · verified for this site` },
|
||||||
|
],
|
||||||
|
checkbox: { id: "always", label: `Always sign me in to ${r.name}` },
|
||||||
|
actions: [{ id: "signin", label: "Sign in", primary: true }, { id: "cancel", label: "Cancel" }],
|
||||||
|
};
|
||||||
|
// The first sign-in is where the user picks which ID this project gets.
|
||||||
|
if (r.first) {
|
||||||
|
const order = r.mode === "one" ? ["one", "project"] : ["project", "one"];
|
||||||
|
opts.select = { id: "mode", label: "Sign in as", options: order.map((m) => ({ value: m, label: modeLabel(m) })) };
|
||||||
|
}
|
||||||
|
const pick = await showApprovalModal(opts, null, r.ctx && r.ctx.tabId != null ? r.ctx.tabId : null);
|
||||||
|
const parts = String(pick || "cancel").split("+");
|
||||||
|
if (parts[0] !== "signin") return { ok: false };
|
||||||
|
const modePart = parts.find((p) => p.startsWith("mode="));
|
||||||
|
return { ok: true, always: parts.includes("always"), mode: modePart ? modePart.slice(5) : r.mode };
|
||||||
|
}
|
||||||
|
function tidErr(err) {
|
||||||
|
const known = ["no-vault", "locked", "denied", "origin-not-listed", "origin-list-unavailable", "bad-request", "busy", "not-top-frame", "state-mismatch", "move-unsupported", "unknown-project"];
|
||||||
|
const code = known.includes(err && err.code) ? err.code : "error";
|
||||||
|
if (code === "error") console.warn("[theseus-id]", err && err.message);
|
||||||
|
return { ok: false, error: { code, message: code === "error" ? "Theseus ID failed" : String(err.message || code) } };
|
||||||
|
}
|
||||||
|
// The caller must be the top frame of a web tab; the origin and URL come
|
||||||
|
// from what that frame has committed, never from the payload.
|
||||||
|
function tidCaller(e) {
|
||||||
|
const t = tabForSender(e.sender);
|
||||||
|
if (!t || t.settings || t.addonId) return { error: { code: "origin-not-listed", message: "this page cannot use Theseus ID" } };
|
||||||
|
if (e.senderFrame !== e.sender.mainFrame) return { error: { code: "not-top-frame", message: "Theseus ID works only in the top frame" } };
|
||||||
|
const url = e.sender.getURL();
|
||||||
|
return { t, origin: pageOriginOf(url), uri: String(url).split("#")[0].replace(/^bns:\/\//i, "https://") };
|
||||||
|
}
|
||||||
|
ipcMain.handle("theseus-id:signIn", async (e, payload) => {
|
||||||
|
const c = tidCaller(e);
|
||||||
|
if (c.error) return { ok: false, error: c.error };
|
||||||
|
const p = payload && typeof payload === "object" ? payload : {};
|
||||||
|
if (JSON.stringify(p).length > 4096) return { ok: false, error: { code: "bad-request", message: "request too large" } };
|
||||||
|
try {
|
||||||
|
const result = await theseusId().signIn({
|
||||||
|
projectId: p.projectId, nonce: p.nonce, statement: p.statement, requestId: p.requestId,
|
||||||
|
resources: p.resources, expiresIn: p.expiresIn, gesture: !!p.gesture,
|
||||||
|
origin: c.origin, uri: c.uri, ctx: { tabId: c.t.id },
|
||||||
|
});
|
||||||
|
return { ok: true, result };
|
||||||
|
} catch (err) { return tidErr(err); }
|
||||||
|
});
|
||||||
|
ipcMain.handle("theseus-id:moved", async (e, payload) => {
|
||||||
|
const c = tidCaller(e);
|
||||||
|
if (c.error) return { ok: false, error: c.error };
|
||||||
|
try { return { ok: true, result: await theseusId().moved({ projectId: String(payload?.projectId || ""), account: String(payload?.account || ""), origin: c.origin }) }; }
|
||||||
|
catch (err) { return tidErr(err); }
|
||||||
|
});
|
||||||
|
// Settings › Theseus ID
|
||||||
|
const tidSettings = (fn) => async (_e, ...args) => { try { return { ok: true, result: await fn(...args) }; } catch (err) { return tidErr(err); } };
|
||||||
|
// mnemonicVault: whether a recovery phrase can rebuild the IDs (only mnemonic vaults carry messengerRoot).
|
||||||
|
ipcMain.handle("theseus-id-overview", tidSettings(async () => ({ ...(await theseusId().overview()), mnemonicVault: !!(vaultState && vaultState.messengerRoot) })));
|
||||||
|
ipcMain.handle("theseus-id-set-default-mode", tidSettings((mode) => theseusId().setDefaultMode(mode)));
|
||||||
|
ipcMain.handle("theseus-id-set-auto", tidSettings((on) => theseusId().setAutoFirstParty(on)));
|
||||||
|
ipcMain.handle("theseus-id-set-always", tidSettings((pid, on) => theseusId().setAlways(String(pid), on)));
|
||||||
|
ipcMain.handle("theseus-id-revoke", tidSettings((pid) => theseusId().revoke(String(pid))));
|
||||||
|
ipcMain.handle("theseus-id-move", tidSettings((pid, to) => theseusId().requestMove(String(pid), { mode: to && to.mode, gen: to && to.gen })));
|
||||||
|
ipcMain.handle("theseus-id-cancel-move", tidSettings((pid) => theseusId().cancelMove(String(pid))));
|
||||||
|
ipcMain.handle("theseus-id-rotate", tidSettings((pid) => theseusId().rotate(String(pid))));
|
||||||
|
ipcMain.handle("theseus-id-unlock", tidSettings(() => requestVaultUnlock({ reason: "Open your Theseus ID." })));
|
||||||
|
// The recovery key is the identity root: behind a fresh PIN / password check.
|
||||||
|
ipcMain.handle("theseus-id-recovery-key", tidSettings(async () => {
|
||||||
|
const c = await requestVaultUnlock({ confirm: true, reason: "Confirm it's you to show your Theseus ID recovery key." });
|
||||||
|
if (!c.ok) throw Object.assign(new Error("cancelled"), { code: "denied" });
|
||||||
|
return theseusId().recoveryKey();
|
||||||
|
}));
|
||||||
// The chrome sends arrow-up/down/enter through so the picker can move its
|
// The chrome sends arrow-up/down/enter through so the picker can move its
|
||||||
// selection cursor without stealing focus from the address input.
|
// selection cursor without stealing focus from the address input.
|
||||||
ipcMain.handle("address-cursor", (_e, dir) => {
|
ipcMain.handle("address-cursor", (_e, dir) => {
|
||||||
|
|
@ -8808,13 +9253,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
|
||||||
// Add-on page-inject bridges ride the same session-wide slot; the
|
// Add-on page-inject bridges ride the same session-wide slot; the
|
||||||
// preload asks main which (if any) apply to the tab it runs in.
|
// preload asks main which (if any) apply to the tab it runs in.
|
||||||
const injectPreload = path.join(__dirname, "addon-inject-preload.js");
|
const injectPreload = path.join(__dirname, "addon-inject-preload.js");
|
||||||
|
// window.theseusId (DESIGN-theseus-id.md §5.1); main answers only top frames of web tabs.
|
||||||
|
const tidPreload = path.join(__dirname, "theseus-id-preload.js");
|
||||||
const ses = session.defaultSession;
|
const ses = session.defaultSession;
|
||||||
if (typeof ses.registerPreloadScript === "function") {
|
if (typeof ses.registerPreloadScript === "function") {
|
||||||
// Electron ≥ 35: setPreloads is deprecated in favour of per-script registration.
|
// Electron ≥ 35: setPreloads is deprecated in favour of per-script registration.
|
||||||
for (const filePath of [bcnrPreload, injectPreload]) ses.registerPreloadScript({ type: "frame", filePath });
|
for (const filePath of [bcnrPreload, injectPreload, tidPreload]) ses.registerPreloadScript({ type: "frame", filePath });
|
||||||
} else {
|
} else {
|
||||||
const existing = ses.getPreloads();
|
const existing = ses.getPreloads();
|
||||||
const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p));
|
const wanted = [bcnrPreload, injectPreload, tidPreload].filter((p) => !existing.includes(p));
|
||||||
if (wanted.length) ses.setPreloads([...existing, ...wanted]);
|
if (wanted.length) ses.setPreloads([...existing, ...wanted]);
|
||||||
}
|
}
|
||||||
} catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); }
|
} catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); }
|
||||||
|
|
@ -8897,6 +9344,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
|
||||||
cookies: settings.clearCookiesOnQuit,
|
cookies: settings.clearCookiesOnQuit,
|
||||||
cache: settings.clearCacheOnQuit,
|
cache: settings.clearCacheOnQuit,
|
||||||
storage: settings.clearStorageOnQuit,
|
storage: settings.clearStorageOnQuit,
|
||||||
|
keep: settings.keepSignInsOnQuit ? signinSites().keepOrigins() : [],
|
||||||
});
|
});
|
||||||
// Session file AND the address-bar history (history.json).
|
// Session file AND the address-bar history (history.json).
|
||||||
if (settings.clearHistoryOnQuit) {
|
if (settings.clearHistoryOnQuit) {
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "theseus-navigator",
|
"name": "theseus-navigator",
|
||||||
"version": "0.3.79",
|
"version": "0.3.80",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
"approval.html",
|
"approval.html",
|
||||||
"unlock.html",
|
"unlock.html",
|
||||||
"unlock-preload.js",
|
"unlock-preload.js",
|
||||||
|
"theseus-id-preload.js",
|
||||||
"auth-prompt-preload.js",
|
"auth-prompt-preload.js",
|
||||||
"auth-prompt.html",
|
"auth-prompt.html",
|
||||||
"addon-inject-preload.js",
|
"addon-inject-preload.js",
|
||||||
|
|
@ -127,6 +128,14 @@
|
||||||
"from": "../Argus/src/lib/password-vault.js",
|
"from": "../Argus/src/lib/password-vault.js",
|
||||||
"to": "password-vault.mjs"
|
"to": "password-vault.mjs"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../TheseusID/lib",
|
||||||
|
"to": "theseus-id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../TheseusID/registry/projects.json",
|
||||||
|
"to": "theseus-id-projects.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../Argus/src/lib/bns-index-core.js",
|
"from": "../Argus/src/lib/bns-index-core.js",
|
||||||
"to": "bns-index-core.mjs"
|
"to": "bns-index-core.mjs"
|
||||||
|
|
|
||||||
12
pw-fill.html
12
pw-fill.html
|
|
@ -25,14 +25,18 @@
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const esc = (s) => String(s || "").replace(/</g, "<");
|
const esc = (s) => String(s || "").replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
||||||
function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
|
function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
|
||||||
window.pwfill.onMatches((data) => {
|
window.pwfill.onMatches((data) => {
|
||||||
const list = $("list");
|
const list = $("list");
|
||||||
const matches = data.matches || [];
|
const matches = data.matches || [];
|
||||||
if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; }
|
document.querySelector(".hdr").textContent = data.locked ? "Theseus Vault" : "Fill password for this site";
|
||||||
list.innerHTML = matches.map((m) =>
|
if (data.locked) {
|
||||||
`<div class="item" data-id="${esc(m.id)}"><span class="ic">🔑</span><span class="txt"><div class="u">${esc(m.username || "(no username)")}</div><div class="d">${esc(m.domain)}</div></span></div>`
|
// The vault is locked, but it has a login for this site.
|
||||||
|
list.innerHTML = `<div class="item" data-id="__unlock"><span class="ic">🔒</span><span class="txt"><div class="u">Sign in with a saved login</div><div class="d">Unlock your vault to fill ${esc(data.host)}</div></span></div>`;
|
||||||
|
} else if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; }
|
||||||
|
else list.innerHTML = matches.map((m) =>
|
||||||
|
`<div class="item" data-id="${esc(m.id)}"><span class="ic">🔑</span><span class="txt"><div class="u">${esc(m.username || "(no username)")}</div><div class="d">${esc(m.domain)}${m.confirm ? " · asks for your PIN" : ""}</div></span></div>`
|
||||||
).join("");
|
).join("");
|
||||||
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id));
|
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id));
|
||||||
report();
|
report();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,20 @@ contextBridge.exposeInMainWorld("cfg", {
|
||||||
pinStatus: () => ipcRenderer.invoke("vault-pin-status"),
|
pinStatus: () => ipcRenderer.invoke("vault-pin-status"),
|
||||||
pinSet: (pin, masterPassword) => ipcRenderer.invoke("vault-pin-set", { pin, masterPassword }),
|
pinSet: (pin, masterPassword) => ipcRenderer.invoke("vault-pin-set", { pin, masterPassword }),
|
||||||
pinClear: () => ipcRenderer.invoke("vault-pin-clear"),
|
pinClear: () => ipcRenderer.invoke("vault-pin-clear"),
|
||||||
|
pinCheckMaster: (masterPassword) => ipcRenderer.invoke("vault-check-master", masterPassword),
|
||||||
|
// Asks for the PIN or master password even while the vault is open.
|
||||||
|
pwConfirm: (reason) => ipcRenderer.invoke("vault-confirm", reason),
|
||||||
|
// Settings › Theseus ID. Each resolves { ok, result } or { ok: false, error: { code, message } }.
|
||||||
|
tidOverview: () => ipcRenderer.invoke("theseus-id-overview"),
|
||||||
|
tidSetDefaultMode: (mode) => ipcRenderer.invoke("theseus-id-set-default-mode", mode),
|
||||||
|
tidSetAuto: (on) => ipcRenderer.invoke("theseus-id-set-auto", !!on),
|
||||||
|
tidSetAlways: (projectId, on) => ipcRenderer.invoke("theseus-id-set-always", projectId, !!on),
|
||||||
|
tidRevoke: (projectId) => ipcRenderer.invoke("theseus-id-revoke", projectId),
|
||||||
|
tidMove: (projectId, to) => ipcRenderer.invoke("theseus-id-move", projectId, to),
|
||||||
|
tidCancelMove: (projectId) => ipcRenderer.invoke("theseus-id-cancel-move", projectId),
|
||||||
|
tidRotate: (projectId) => ipcRenderer.invoke("theseus-id-rotate", projectId),
|
||||||
|
tidUnlock: () => ipcRenderer.invoke("theseus-id-unlock"),
|
||||||
|
tidRecoveryKey: () => ipcRenderer.invoke("theseus-id-recovery-key"),
|
||||||
pinUnlock: () => ipcRenderer.invoke("vault-pin-unlock"),
|
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.
|
||||||
|
|
|
||||||
385
settings.html
385
settings.html
|
|
@ -259,6 +259,34 @@
|
||||||
.ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; }
|
.ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; }
|
||||||
.ctxmenu .mi:hover{ background:rgba(37,58,73,.06); }
|
.ctxmenu .mi:hover{ background:rgba(37,58,73,.06); }
|
||||||
}
|
}
|
||||||
|
/* PIN setup dialog — the same pad as the unlock prompt and Aegis. */
|
||||||
|
.pinwiz{position:fixed;inset:0;background:rgba(0,0,0,.5);display:grid;place-items:center;z-index:50;padding:16px}
|
||||||
|
/* display:grid / display:flex would otherwise beat the hidden attribute */
|
||||||
|
.pinwiz[hidden], .row[hidden]{display:none}
|
||||||
|
.pinwiz-box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px 16px;width:min(400px,100%);box-shadow:0 20px 60px #000c}
|
||||||
|
.pinwiz-box h3{margin:6px 0 4px;font-size:17px}
|
||||||
|
.pinwiz-text{color:var(--mut);font-size:13px;line-height:20px;min-height:40px;margin:0 0 14px}
|
||||||
|
.pinwiz-steps{display:flex;gap:6px}
|
||||||
|
.pinwiz-steps span{flex:1;height:3px;border-radius:2px;background:var(--line)}
|
||||||
|
.pinwiz-steps span.on{background:var(--acid)}
|
||||||
|
.pinwiz-box input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:inherit;font-size:14px}
|
||||||
|
.pinwiz-err{color:#f6768a;font-size:12.5px;line-height:18px;min-height:36px;margin:8px 0 2px;text-align:center}
|
||||||
|
.pinwiz-act{display:flex;justify-content:space-between;gap:8px;margin-top:6px}
|
||||||
|
.pinwiz-len{display:flex;justify-content:center;gap:6px;margin:0 0 12px}
|
||||||
|
.pinwiz-len button{border:1px solid var(--line);background:transparent;color:var(--mut);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer}
|
||||||
|
.pinwiz-len button.on{border-color:var(--acid);color:var(--acid-text);background:rgb(from var(--acid) r g b / .12)}
|
||||||
|
.pinpad{display:flex;flex-direction:column;align-items:center;gap:14px}
|
||||||
|
.pinpad .pindots{display:flex;gap:12px}
|
||||||
|
.pinpad .pindot{width:12px;height:12px;border-radius:50%;border:1.5px solid var(--dim);transition:background .12s,border-color .12s}
|
||||||
|
.pinpad .pindot.on{background:var(--acid);border-color:var(--acid)}
|
||||||
|
.pinpad .pinkeys{display:grid;grid-template-columns:repeat(3,62px);gap:8px}
|
||||||
|
.pinpad .pinkeys button{height:46px;border-radius:10px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:500 18px system-ui,sans-serif;cursor:pointer}
|
||||||
|
.pinpad .pinkeys button:hover{border-color:var(--acid);color:var(--acid-text)}
|
||||||
|
.pinpad .pinkeys button.util{background:transparent;font-size:13px;color:var(--dim)}
|
||||||
|
@media (prefers-color-scheme: light){
|
||||||
|
.pinwiz-box input[type=password], .pinpad .pinkeys button{background:#f1f5f8;color:#253A49}
|
||||||
|
.pinpad .pinkeys button.util{background:transparent}
|
||||||
|
}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
@ -269,6 +297,7 @@
|
||||||
<a data-sec="language">Language</a>
|
<a data-sec="language">Language</a>
|
||||||
<a data-sec="search">Search</a>
|
<a data-sec="search">Search</a>
|
||||||
<a data-sec="passwords">Passwords</a>
|
<a data-sec="passwords">Passwords</a>
|
||||||
|
<a data-sec="theseusid">Theseus ID</a>
|
||||||
<a data-sec="performance">Performance</a>
|
<a data-sec="performance">Performance</a>
|
||||||
<a data-sec="privacy">Privacy</a>
|
<a data-sec="privacy">Privacy</a>
|
||||||
<a data-sec="plugins">Plug-ins</a>
|
<a data-sec="plugins">Plug-ins</a>
|
||||||
|
|
@ -594,13 +623,8 @@
|
||||||
<!-- State B: vault exists but locked -->
|
<!-- State B: vault exists but locked -->
|
||||||
<div id="pwLocked" hidden>
|
<div id="pwLocked" hidden>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="txt"><div class="t">Unlock vault</div><div class="d">Enter your master password to view or add entries.</div></div>
|
<div class="txt"><div class="t">Your vault is locked</div><div class="d" id="pwLockedDesc">Unlock it with your master password to view or add entries.</div></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 class="ctl"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
|
||||||
</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">Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
|
|
||||||
<div class="ctl"><button id="pwPinUnlockBtn" class="btn" type="button">Unlock with PIN</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- State C: vault unlocked -->
|
<!-- State C: vault unlocked -->
|
||||||
|
|
@ -612,14 +636,19 @@
|
||||||
<h2 class="sub">Quick-unlock PIN</h2>
|
<h2 class="sub">Quick-unlock PIN</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="txt"><div class="t">PIN</div>
|
<div class="txt"><div class="t">PIN</div>
|
||||||
<div class="d" id="pinDesc">A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
|
<div class="d" id="pinDesc">A PIN of 6 to 8 digits that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
|
||||||
<div class="ctl"><button id="pinSetBtn" class="btn" type="button">Set a PIN</button><button id="pinClearBtn" class="btn" type="button" hidden>Remove</button></div>
|
<div class="ctl"><button id="pinSetBtn" class="btn" type="button">Set a PIN</button><button id="pinClearBtn" class="btn" type="button" hidden>Remove</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pinForm" class="row" style="flex-direction:column;align-items:stretch;gap:8px" hidden>
|
<!-- Step-by-step PIN setup: master password → choose PIN → repeat → done. -->
|
||||||
<div class="addeng"><input id="pinMaster" type="password" placeholder="Master password" autocomplete="current-password"></div>
|
<div id="pinWiz" class="pinwiz" hidden>
|
||||||
<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 class="pinwiz-box" role="dialog" aria-modal="true" aria-labelledby="pinWizTitle">
|
||||||
<div id="pinErr" class="pmuted" style="color:#f6768a;font-size:12.5px" hidden></div>
|
<div class="pinwiz-steps"><span data-step="1"></span><span data-step="2"></span><span data-step="3"></span></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>
|
<h3 id="pinWizTitle"></h3>
|
||||||
|
<p id="pinWizText" class="pinwiz-text"></p>
|
||||||
|
<div id="pinWizBody"></div>
|
||||||
|
<div id="pinWizErr" class="pinwiz-err"></div>
|
||||||
|
<div class="pinwiz-act"><button id="pinWizCancel" class="btn ghost" type="button">Cancel</button><button id="pinWizNext" class="btn" type="button">Next</button></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pwList"></div>
|
<div id="pwList"></div>
|
||||||
<h2 class="sub">Add an entry</h2>
|
<h2 class="sub">Add an entry</h2>
|
||||||
|
|
@ -633,7 +662,61 @@
|
||||||
<div class="addeng"><button id="pwAddPreview" class="btn" type="button" style="flex:none">Preview</button><input id="pwAddPreviewOut" readonly placeholder="preview appears here" style="font-family:ui-monospace,monospace"></div>
|
<div class="addeng"><button id="pwAddPreview" class="btn" type="button" style="flex:none">Preview</button><input id="pwAddPreviewOut" readonly placeholder="preview appears here" style="font-family:ui-monospace,monospace"></div>
|
||||||
<div style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></div>
|
<div style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="note">On a site with a saved login, a key appears in the address bar while the vault is unlocked: click it to fill the login.</div>
|
<div class="note">On a site with a saved login, Theseus offers it under the sign-in form, and a key appears in the address bar while the vault is unlocked.</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="sub">Signing in</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Offer to save passwords</div><div class="d">After you sign in or sign up on a site, Theseus asks whether to keep the login in your vault. You can also ask it to check your PIN or password before it fills that login.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="pwOfferSave"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Offer saved logins on sign-in forms</div><div class="d">When you click into a sign-in form, saved logins for that site appear under it. While the vault is locked, Theseus offers to unlock it.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="pwOfferFill"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row" id="pwNeverRow" hidden>
|
||||||
|
<div class="txt"><div class="t">Never saved</div><div class="d" id="pwNeverList"></div></div>
|
||||||
|
<div class="ctl"><button id="pwNeverClear" class="btn ghost" type="button">Clear list</button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- THESEUS ID: DESIGN-theseus-id.md §5.5 -->
|
||||||
|
<section id="theseusid" hidden>
|
||||||
|
<h1>Theseus ID</h1>
|
||||||
|
<p class="lede">Your Silent Mode account, kept in your Theseus Vault. Sign in once and Silent Mode projects know you. A project gets a signature from your ID, never a key, and it can't see your wallets unless you link one.</p>
|
||||||
|
<div id="tidNoVault" hidden>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Set up the Theseus Vault first</div><div class="d">Your Theseus ID is made from your vault, so the same recovery phrase gives you the same ID on every device.</div></div>
|
||||||
|
<div class="ctl"><button class="btn" type="button" data-go="passwords">Set up the vault</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tidLocked" hidden>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Your vault is locked</div><div class="d">Unlock it to see the projects you sign in to.</div></div>
|
||||||
|
<div class="ctl"><button id="tidUnlockBtn" class="btn" type="button">Unlock</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tidMain" hidden>
|
||||||
|
<h2 class="sub">How projects see you</h2>
|
||||||
|
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||||||
|
<label class="polrow"><input type="radio" name="tidMode" value="project"><span><b>A different ID for each project</b> <span class="pmuted">— recommended. Projects can't tell your IDs belong to the same person.</span></span></label>
|
||||||
|
<label class="polrow"><input type="radio" name="tidMode" value="one"><span><b>One ID for every project</b> <span class="pmuted">— projects can recognise you as the same person.</span></span></label>
|
||||||
|
<div class="pmuted" style="font-size:12.5px">This applies to projects you haven't signed in to yet. A project you already use keeps its ID unless you change it below.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Your One ID</div><div class="d"><code id="tidOneId" style="word-break:break-all"></code></div></div>
|
||||||
|
<div class="ctl"><button id="tidCopyOne" class="btn ghost" type="button">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">Sign in to Silent Mode projects automatically</div><div class="d">After the first time, Hephaestus, Sirius, Pithos and other Silent Mode projects sign you in without asking while your vault is unlocked. Other sites always ask, unless you tick "Always sign me in" for them.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="tidAuto"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
|
<h2 class="sub">Signed-in projects</h2>
|
||||||
|
<div id="tidProjects"></div>
|
||||||
|
<h2 class="sub">Recovery</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="txt"><div class="t">If you lose this device</div><div class="d" id="tidRecoveryText"></div></div>
|
||||||
|
<div class="ctl"><button id="tidRecoveryBtn" class="btn ghost" type="button">Show recovery key</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="tidRecoveryOut" class="row" hidden style="flex-direction:column;align-items:stretch"><code id="tidRecoveryKey" style="word-break:break-all"></code><div class="pmuted" style="font-size:12.5px">This 64-character key restores your Theseus IDs. Keep it somewhere safe and private: anyone with it can sign in as you. It is a secret key, not a recovery phrase.</div></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<!-- NAMING -->
|
<!-- NAMING -->
|
||||||
|
|
@ -815,6 +898,10 @@
|
||||||
<div class="txt"><div class="t">Clear cookies on quit</div><div class="d">Drops session + persistent cookies. You'll sign in again next launch.</div></div>
|
<div class="txt"><div class="t">Clear cookies on quit</div><div class="d">Drops session + persistent cookies. You'll sign in again next launch.</div></div>
|
||||||
<label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label>
|
<label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin-left:24px">
|
||||||
|
<div class="txt"><div class="t">Keep sign-ins for sites in your vault</div><div class="d">Sites with a login saved in Settings › Passwords keep their cookies and site storage, so you stay signed in. Everything else is still cleared. If a site signs you out anyway, Theseus offers your saved login on its sign-in form.</div></div>
|
||||||
|
<label class="sw"><input type="checkbox" id="keepSignInsOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="txt"><div class="t">Clear HTTP cache on quit</div><div class="d">Drops cached images / scripts / stylesheets. Sites re-download; small disk win.</div></div>
|
<div class="txt"><div class="t">Clear HTTP cache on quit</div><div class="d">Drops cached images / scripts / stylesheets. Sites re-download; small disk win.</div></div>
|
||||||
<label class="sw"><input type="checkbox" id="clearCacheOnQuit"><span class="track"><span class="knob"></span></span></label>
|
<label class="sw"><input type="checkbox" id="clearCacheOnQuit"><span class="track"><span class="knob"></span></span></label>
|
||||||
|
|
@ -1169,7 +1256,8 @@
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
|
||||||
})();
|
})();
|
||||||
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
|
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
|
||||||
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
|
"clearCookiesOnQuit", "keepSignInsOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
|
||||||
|
"pwOfferSave", "pwOfferFill",
|
||||||
"quickLinksShow", "translateAutoOffer",
|
"quickLinksShow", "translateAutoOffer",
|
||||||
"sidebarHideOnNewTab", "sidebarHideOnRightPanel", "sidebarHideOnPageClick"];
|
"sidebarHideOnNewTab", "sidebarHideOnRightPanel", "sidebarHideOnPageClick"];
|
||||||
// Text inputs that round-trip through settings-set on change. Trimmed; a
|
// Text inputs that round-trip through settings-set on change. Trimmed; a
|
||||||
|
|
@ -2218,12 +2306,23 @@
|
||||||
pwLockedEl.hidden = which !== "locked";
|
pwLockedEl.hidden = which !== "locked";
|
||||||
pwUnlockedEl.hidden = which !== "unlocked";
|
pwUnlockedEl.hidden = which !== "unlocked";
|
||||||
}
|
}
|
||||||
|
async function pwRenderNever() {
|
||||||
|
const s = await C.get();
|
||||||
|
const list = Array.isArray(s.pwNeverSave) ? s.pwNeverSave : [];
|
||||||
|
document.getElementById("pwNeverRow").hidden = !list.length;
|
||||||
|
document.getElementById("pwNeverList").textContent = list.length
|
||||||
|
? `Theseus won't offer to save passwords on: ${list.join(", ")}.` : "";
|
||||||
|
}
|
||||||
|
document.getElementById("pwNeverClear").onclick = async () => { await C.set("pwNeverSave", []); pwRenderNever(); };
|
||||||
async function pwRefresh() {
|
async function pwRefresh() {
|
||||||
|
pwRenderNever().catch(() => {});
|
||||||
const st = await C.pwStatus();
|
const st = await C.pwStatus();
|
||||||
if (!st.setup) return pwShow("setup");
|
if (!st.setup) return pwShow("setup");
|
||||||
const pin = await C.pinStatus().catch(() => null);
|
const pin = await C.pinStatus().catch(() => null);
|
||||||
if (!st.unlocked) {
|
if (!st.unlocked) {
|
||||||
document.getElementById("pwPinUnlockRow").hidden = !(pin && pin.pinSet);
|
document.getElementById("pwLockedDesc").textContent = pin && pin.pinSet
|
||||||
|
? "Unlock it with your PIN or master password to view or add entries."
|
||||||
|
: "Unlock it with your master password to view or add entries.";
|
||||||
return pwShow("locked");
|
return pwShow("locked");
|
||||||
}
|
}
|
||||||
pwShow("unlocked");
|
pwShow("unlocked");
|
||||||
|
|
@ -2237,7 +2336,7 @@
|
||||||
document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
|
document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
|
||||||
document.getElementById("pinClearBtn").hidden = !set;
|
document.getElementById("pinClearBtn").hidden = !set;
|
||||||
const desc = document.getElementById("pinDesc");
|
const desc = document.getElementById("pinDesc");
|
||||||
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.";
|
const base = "A PIN of 6 to 8 digits that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.";
|
||||||
desc.textContent = set && pin.lockedMs > 0 ? base + ` Locked after wrong tries — it works again in ${Math.max(1, Math.ceil(pin.lockedMs / 60000))} min, or at once after a master-password unlock.`
|
desc.textContent = set && pin.lockedMs > 0 ? base + ` Locked after wrong tries — it works again in ${Math.max(1, Math.ceil(pin.lockedMs / 60000))} min, or at once after a master-password unlock.`
|
||||||
: set && pin.hardware === "tpm" ? base + " It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
|
: set && pin.hardware === "tpm" ? base + " It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
|
||||||
: set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password."
|
: set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password."
|
||||||
|
|
@ -2245,33 +2344,126 @@
|
||||||
: base;
|
: base;
|
||||||
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
|
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
|
||||||
}
|
}
|
||||||
const pinForm = document.getElementById("pinForm");
|
// Step-by-step PIN setup. Step 1 proves the master password (the PIN
|
||||||
const pinErr = document.getElementById("pinErr");
|
// wraps it), step 2 picks the PIN on a pad, step 3 repeats it, then
|
||||||
document.getElementById("pinSetBtn").onclick = () => { pinForm.hidden = false; pinErr.hidden = true; document.getElementById("pinMaster").focus(); };
|
// main seals it. The master password stays in this closure only until
|
||||||
document.getElementById("pinCancel").onclick = () => {
|
// the dialog closes.
|
||||||
pinForm.hidden = true;
|
const pinWiz = {
|
||||||
for (const id of ["pinMaster", "pinNew1", "pinNew2"]) document.getElementById(id).value = "";
|
el: document.getElementById("pinWiz"),
|
||||||
};
|
step: 0, master: "", first: "", entry: "", len: 6, busy: false,
|
||||||
document.getElementById("pinSave").onclick = async () => {
|
open() {
|
||||||
pinErr.hidden = true;
|
Object.assign(this, { step: 1, master: "", first: "", entry: "", len: 6, busy: false });
|
||||||
const master = document.getElementById("pinMaster").value;
|
this.el.hidden = false;
|
||||||
const p1 = document.getElementById("pinNew1").value;
|
this.render();
|
||||||
const p2 = document.getElementById("pinNew2").value;
|
},
|
||||||
const fail = (m) => { pinErr.textContent = m; pinErr.hidden = false; };
|
close() {
|
||||||
if (!/^\d{6}$/.test(p1)) return fail("The PIN must be 6 digits.");
|
Object.assign(this, { step: 0, master: "", first: "", entry: "" });
|
||||||
if (p1 !== p2) return fail("The two PINs don't match.");
|
this.el.hidden = true;
|
||||||
if (!master) return fail("Enter your master password to bind the PIN to it.");
|
},
|
||||||
const res = await C.pinSet(p1, master);
|
error(msg) { document.getElementById("pinWizErr").textContent = msg || ""; },
|
||||||
if (!res.ok) return fail(res.err === "wrong master password" ? "Wrong master password." : res.err);
|
press(k) {
|
||||||
document.getElementById("pinCancel").click();
|
if (this.busy || (this.step !== 2 && this.step !== 3)) return;
|
||||||
pwRefresh();
|
if (k === "back") this.entry = this.entry.slice(0, -1);
|
||||||
|
else if (k === "clear") this.entry = "";
|
||||||
|
else if (this.entry.length < this.len) this.entry += k;
|
||||||
|
this.error("");
|
||||||
|
this.paintDots();
|
||||||
|
if (this.entry.length === this.len) setTimeout(() => this.next(), 120);
|
||||||
|
},
|
||||||
|
paintDots() {
|
||||||
|
document.querySelectorAll("#pinWizBody .pindot").forEach((d, i) => d.classList.toggle("on", i < this.entry.length));
|
||||||
|
},
|
||||||
|
pad() {
|
||||||
|
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
||||||
|
return `<div class="pinpad"><div class="pindots">${Array.from({ length: this.len }, () => `<span class="pindot"></span>`).join("")}</div>` +
|
||||||
|
`<div class="pinkeys">${keys.map((k) => `<button type="button" data-k="${k}" class="${k === "back" || k === "clear" ? "util" : ""}" aria-label="${k === "back" ? "Delete" : k === "clear" ? "Clear" : k}">${k === "back" ? "⌫" : k === "clear" ? "Clear" : k}</button>`).join("")}</div></div>`;
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
const title = document.getElementById("pinWizTitle"), text = document.getElementById("pinWizText");
|
||||||
|
const body = document.getElementById("pinWizBody"), nextBtn = document.getElementById("pinWizNext");
|
||||||
|
document.querySelectorAll(".pinwiz-steps span").forEach((s) => s.classList.toggle("on", +s.dataset.step <= this.step));
|
||||||
|
this.error("");
|
||||||
|
if (this.step === 1) {
|
||||||
|
title.textContent = "Confirm your master password";
|
||||||
|
text.textContent = "The PIN unlocks the vault by opening your master password, so Theseus needs it once to set the PIN up.";
|
||||||
|
body.innerHTML = `<input id="pinWizPw" type="password" placeholder="Master password" autocomplete="current-password">`;
|
||||||
|
const inp = document.getElementById("pinWizPw");
|
||||||
|
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") this.next(); });
|
||||||
|
setTimeout(() => inp.focus(), 0);
|
||||||
|
nextBtn.hidden = false; nextBtn.textContent = "Next";
|
||||||
|
} else if (this.step === 2) {
|
||||||
|
title.textContent = "Choose a PIN";
|
||||||
|
text.textContent = "Pick how many digits, then type them. Longer is stronger.";
|
||||||
|
body.innerHTML = `<div class="pinwiz-len">${[6, 7, 8].map((n) => `<button type="button" data-len="${n}" class="${n === this.len ? "on" : ""}">${n} digits</button>`).join("")}</div>` + this.pad();
|
||||||
|
body.querySelectorAll("[data-len]").forEach((b) => b.onclick = () => { this.len = +b.dataset.len; this.entry = ""; this.render(); });
|
||||||
|
nextBtn.hidden = true;
|
||||||
|
} else if (this.step === 3) {
|
||||||
|
title.textContent = "Repeat your PIN";
|
||||||
|
text.textContent = `Type the same ${this.len} digits again.`;
|
||||||
|
// The same (hidden) length row as step 2, so the pad does not move up.
|
||||||
|
body.innerHTML = `<div class="pinwiz-len" style="visibility:hidden" aria-hidden="true"><button type="button" tabindex="-1">8 digits</button></div>` + this.pad();
|
||||||
|
nextBtn.hidden = true;
|
||||||
|
} else if (this.step === 4) {
|
||||||
|
title.textContent = "PIN set";
|
||||||
|
text.textContent = this.hardware === "tpm"
|
||||||
|
? "Your PIN now unlocks the vault here, in Aegis and in extensions. It is tied to this computer's security chip, which allows only a few wrong guesses an hour."
|
||||||
|
: "Your PIN now unlocks the vault here, in Aegis and in extensions. This computer has no usable security chip, so the PIN only stops casual use.";
|
||||||
|
body.innerHTML = "";
|
||||||
|
nextBtn.hidden = false; nextBtn.textContent = "Done";
|
||||||
|
}
|
||||||
|
body.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => this.press(b.dataset.k));
|
||||||
|
document.getElementById("pinWizCancel").hidden = this.step === 4;
|
||||||
|
},
|
||||||
|
async next() {
|
||||||
|
if (this.busy) return;
|
||||||
|
if (this.step === 1) {
|
||||||
|
const pw = document.getElementById("pinWizPw").value;
|
||||||
|
if (!pw) return this.error("Enter your master password.");
|
||||||
|
this.busy = true;
|
||||||
|
const r = await C.pinCheckMaster(pw).catch((e) => ({ ok: false, err: e.message }));
|
||||||
|
this.busy = false;
|
||||||
|
if (!r.ok) return this.error("Wrong master password.");
|
||||||
|
this.master = pw; this.step = 2; this.render();
|
||||||
|
} else if (this.step === 2) {
|
||||||
|
if (this.entry.length !== this.len) return;
|
||||||
|
this.first = this.entry; this.entry = ""; this.step = 3; this.render();
|
||||||
|
} else if (this.step === 3) {
|
||||||
|
if (this.entry.length !== this.len) return;
|
||||||
|
if (this.entry !== this.first) {
|
||||||
|
this.first = ""; this.entry = ""; this.step = 2; this.render();
|
||||||
|
return this.error("The PINs didn't match. Choose your PIN again.");
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
document.getElementById("pinWizText").textContent = "Saving… the security chip can take a few seconds.";
|
||||||
|
const res = await C.pinSet(this.first, this.master).catch((e) => ({ ok: false, err: e.message }));
|
||||||
|
this.busy = false;
|
||||||
|
if (!res.ok) { this.entry = ""; this.render(); return this.error(res.err === "wrong master password" ? "Wrong master password." : res.err); }
|
||||||
|
this.hardware = res.hardware; this.master = ""; this.first = ""; this.entry = "";
|
||||||
|
this.step = 4; this.render();
|
||||||
|
pwRefresh();
|
||||||
|
} else if (this.step === 4) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
document.getElementById("pinSetBtn").onclick = () => pinWiz.open();
|
||||||
|
document.getElementById("pinWizCancel").onclick = () => pinWiz.close();
|
||||||
|
document.getElementById("pinWizNext").onclick = () => pinWiz.next();
|
||||||
|
pinWiz.el.addEventListener("mousedown", (e) => { if (e.target === pinWiz.el && pinWiz.step !== 4) pinWiz.close(); });
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (pinWiz.el.hidden) return;
|
||||||
|
if (e.key === "Escape") return pinWiz.close();
|
||||||
|
if (/^[0-9]$/.test(e.key)) pinWiz.press(e.key);
|
||||||
|
else if (e.key === "Backspace" && (pinWiz.step === 2 || pinWiz.step === 3)) pinWiz.press("back");
|
||||||
|
});
|
||||||
document.getElementById("pinClearBtn").onclick = async () => {
|
document.getElementById("pinClearBtn").onclick = async () => {
|
||||||
if (!confirm("Remove the PIN? The vault will need the master password again.")) return;
|
if (!confirm("Remove the PIN? The vault will need the master password again.")) return;
|
||||||
await C.pinClear();
|
await C.pinClear();
|
||||||
pwRefresh();
|
pwRefresh();
|
||||||
};
|
};
|
||||||
document.getElementById("pwPinUnlockBtn").onclick = async () => {
|
// Unlock opens Theseus's own centred prompt: the PIN pad when a PIN is
|
||||||
|
// set, the master password otherwise.
|
||||||
|
document.getElementById("pwUnlockBtn").onclick = async () => {
|
||||||
const r = await C.pinUnlock();
|
const r = await C.pinUnlock();
|
||||||
if (r && r.ok) pwRefresh();
|
if (r && r.ok) pwRefresh();
|
||||||
};
|
};
|
||||||
|
|
@ -2283,9 +2475,18 @@
|
||||||
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
|
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
|
||||||
`<span class="eic"><span class="es">🔑</span></span>` +
|
`<span class="eic"><span class="es">🔑</span></span>` +
|
||||||
`<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` +
|
`<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` +
|
||||||
|
`<button class="cx pwAsk" title="${e.confirm ? "Asks for your PIN or password before filling. Click to stop asking." : "Fills without asking. Click to require your PIN or password first."}" aria-pressed="${e.confirm ? "true" : "false"}" style="${e.confirm ? "" : "opacity:.35"}">🔒</button>` +
|
||||||
`<button class="cx pwShow" title="Show + copy">👁</button>` +
|
`<button class="cx pwShow" title="Show + copy">👁</button>` +
|
||||||
`<button class="cx pwDel" title="Remove">✕</button>` +
|
`<button class="cx pwDel" title="Remove">✕</button>` +
|
||||||
`</div>`).join("");
|
`</div>`).join("");
|
||||||
|
pwListEl.querySelectorAll(".pwAsk").forEach((b) => b.onclick = async (ev) => {
|
||||||
|
const id = ev.target.closest(".eng").dataset.id;
|
||||||
|
const on = b.getAttribute("aria-pressed") !== "true";
|
||||||
|
// Turning the check off needs the check itself.
|
||||||
|
if (!on) { const r = await C.pwConfirm(`Stop asking before filling this login?`); if (!r || !r.ok) return; }
|
||||||
|
await C.pwUpdate(id, { confirm: on });
|
||||||
|
pwRefresh();
|
||||||
|
});
|
||||||
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
|
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
|
||||||
const id = ev.target.closest(".eng").dataset.id;
|
const id = ev.target.closest(".eng").dataset.id;
|
||||||
const res = await C.pwGet(id);
|
const res = await C.pwGet(id);
|
||||||
|
|
@ -2322,17 +2523,6 @@
|
||||||
document.getElementById("pwSetupMnemonic").value = "";
|
document.getElementById("pwSetupMnemonic").value = "";
|
||||||
pwRefresh();
|
pwRefresh();
|
||||||
};
|
};
|
||||||
// Unlock
|
|
||||||
document.getElementById("pwUnlockBtn").onclick = async () => {
|
|
||||||
const err = document.getElementById("pwUnlockErr");
|
|
||||||
err.hidden = true;
|
|
||||||
const pw = document.getElementById("pwUnlockPw").value;
|
|
||||||
const res = await C.pwUnlock(pw);
|
|
||||||
if (!res.ok) { err.textContent = res.err; err.hidden = false; return; }
|
|
||||||
document.getElementById("pwUnlockPw").value = "";
|
|
||||||
pwRefresh();
|
|
||||||
};
|
|
||||||
document.getElementById("pwUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("pwUnlockBtn").click(); });
|
|
||||||
// Lock
|
// Lock
|
||||||
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
|
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
|
||||||
// Add-entry form: toggle literal input; wire preview + save
|
// Add-entry form: toggle literal input; wire preview + save
|
||||||
|
|
@ -2375,6 +2565,103 @@
|
||||||
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
|
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Settings › Theseus ID (DESIGN-theseus-id.md §5.5) ----
|
||||||
|
// Built with createElement/textContent only: project names come from
|
||||||
|
// origin lists fetched from the projects themselves.
|
||||||
|
(() => {
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
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 === "style") n.style.cssText = 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;
|
||||||
|
};
|
||||||
|
const short = (a) => (a && a.length > 20 ? `${a.slice(0, 12)}…${a.slice(-4)}` : a || "");
|
||||||
|
const when = (iso) => { if (!iso) return "never"; const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); };
|
||||||
|
const unwrap = async (p) => { const r = await p; if (!r || !r.ok) throw Object.assign(new Error(r?.error?.message || "failed"), { code: r?.error?.code }); return r.result; };
|
||||||
|
let view = null;
|
||||||
|
function show(which) { for (const id of ["tidNoVault", "tidLocked", "tidMain"]) $(id).hidden = id !== which; }
|
||||||
|
async function refresh() {
|
||||||
|
let ov;
|
||||||
|
try { ov = await unwrap(C.tidOverview()); } catch (e) { show("tidLocked"); return; }
|
||||||
|
view = ov;
|
||||||
|
if (ov.vault === "none") return show("tidNoVault");
|
||||||
|
if (ov.vault === "locked") return show("tidLocked");
|
||||||
|
show("tidMain");
|
||||||
|
for (const r of document.querySelectorAll('input[name="tidMode"]')) r.checked = r.value === ov.defaultMode;
|
||||||
|
$("tidOneId").textContent = ov.oneId;
|
||||||
|
$("tidAuto").checked = !!ov.autoFirstParty;
|
||||||
|
$("tidRecoveryText").textContent = ov.mnemonicVault
|
||||||
|
? "Your IDs come back from your vault's recovery phrase on any device. The recovery key below does the same job if you keep it instead."
|
||||||
|
: "Your vault was made without a recovery phrase, so only this key (or a backup of the vault file with its master password) brings your IDs back. Write it down somewhere safe.";
|
||||||
|
renderProjects(ov.projects || []);
|
||||||
|
}
|
||||||
|
function renderProjects(list) {
|
||||||
|
const box = $("tidProjects");
|
||||||
|
box.replaceChildren();
|
||||||
|
if (!list.length) { box.append(el("div", { class: "cempty", style: "padding:12px 0" }, "No projects yet. When a site asks you to sign in with Theseus ID, it shows up here.")); return; }
|
||||||
|
for (const p of list) {
|
||||||
|
const other = p.mode === "one" ? "project" : "one";
|
||||||
|
const act = async (fn, after) => {
|
||||||
|
try { await unwrap(fn()); } catch (e) { alert(e.message); }
|
||||||
|
if (after) after();
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
const row = el("div", { class: "row", style: "flex-direction:column;align-items:stretch;gap:6px" },
|
||||||
|
el("div", { style: "display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap" },
|
||||||
|
el("div", {}, el("b", {}, p.name), " ", el("span", { class: "pmuted", style: "font-size:12px" }, p.projectId, p.firstParty ? " · Silent Mode" : "")),
|
||||||
|
el("span", { class: "pmuted", style: "font-size:12px" }, "last used ", when(p.lastUsed))),
|
||||||
|
el("div", { class: "pmuted", style: "font-size:12.5px" },
|
||||||
|
p.mode === "one" ? "Uses your One ID " : "Uses an ID only this project sees ",
|
||||||
|
el("code", { title: p.account }, short(p.account)),
|
||||||
|
p.origins && p.origins.length ? ` · from ${p.origins.join(", ")}` : ""),
|
||||||
|
p.moving ? el("div", { style: "font-size:12.5px;color:var(--acid-text)" },
|
||||||
|
`Moving to ${short(p.moving.to.account)} — ${p.name} confirms the move the next time you sign in. `,
|
||||||
|
el("button", { class: "btn ghost", type: "button", style: "padding:2px 8px;font-size:12px", onclick: () => act(() => C.tidCancelMove(p.projectId)) }, "Cancel move")) : null,
|
||||||
|
el("div", { style: "display:flex;gap:6px;flex-wrap:wrap;align-items:center" },
|
||||||
|
el("label", { class: "polrow", style: "margin-right:auto" },
|
||||||
|
(() => { const c = el("input", { type: "checkbox" }); c.checked = p.always; c.addEventListener("change", () => act(() => C.tidSetAlways(p.projectId, c.checked))); return c; })(),
|
||||||
|
el("span", {}, "Always sign me in")),
|
||||||
|
el("button", { class: "btn ghost", type: "button", title: p.supportsMove ? "" : `${p.name} can't move accounts to a new ID yet`,
|
||||||
|
...(p.supportsMove ? {} : { disabled: "" }),
|
||||||
|
onclick: () => { if (confirm(`Move ${p.name} to ${other === "one" ? "your One ID" : "an ID only it sees"}? Your account there moves with you: the next sign-in proves both IDs are yours.`)) act(() => C.tidMove(p.projectId, { mode: other })); } },
|
||||||
|
other === "one" ? "Use my One ID" : "Use a private ID"),
|
||||||
|
el("button", { class: "btn ghost", type: "button", ...(p.supportsMove ? {} : { disabled: "" }),
|
||||||
|
onclick: () => { if (confirm(`Give ${p.name} a brand-new ID? Do this if you think this ID's key leaked. Your account moves to the new ID at the next sign-in.`)) act(() => C.tidRotate(p.projectId)); } }, "New ID"),
|
||||||
|
el("button", { class: "btn ghost", type: "button",
|
||||||
|
onclick: () => { if (confirm(`Stop signing in to ${p.name}? Theseus forgets this project and won't sign you in there again until you approve it. Your account at ${p.name} still exists.`)) act(() => C.tidRevoke(p.projectId)); } }, "Revoke")));
|
||||||
|
box.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of document.querySelectorAll('input[name="tidMode"]')) r.addEventListener("change", async () => {
|
||||||
|
try { await unwrap(C.tidSetDefaultMode(r.value)); } catch (e) { alert(e.message); }
|
||||||
|
const using = (view && view.projects || []).filter((p) => p.mode !== r.value);
|
||||||
|
if (using.length) alert(`New projects will use ${r.value === "one" ? "your One ID" : "a private ID each"}. The ${using.length} project${using.length === 1 ? "" : "s"} you already use keep their IDs; change them one by one below.`);
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
$("tidAuto").addEventListener("change", async () => { try { await unwrap(C.tidSetAuto($("tidAuto").checked)); } catch (e) { alert(e.message); } refresh(); });
|
||||||
|
$("tidCopyOne").onclick = async () => { try { await navigator.clipboard.writeText($("tidOneId").textContent); $("tidCopyOne").textContent = "Copied"; setTimeout(() => ($("tidCopyOne").textContent = "Copy"), 1500); } catch {} };
|
||||||
|
$("tidUnlockBtn").onclick = async () => { await C.tidUnlock(); refresh(); };
|
||||||
|
$("tidRecoveryBtn").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const key = await unwrap(C.tidRecoveryKey());
|
||||||
|
$("tidRecoveryKey").textContent = key;
|
||||||
|
$("tidRecoveryOut").hidden = false;
|
||||||
|
} catch (e) { if (e.code !== "denied") alert(e.message); }
|
||||||
|
};
|
||||||
|
// The recovery key never stays on screen once the user leaves the page.
|
||||||
|
document.addEventListener("section", (e) => {
|
||||||
|
$("tidRecoveryOut").hidden = true; $("tidRecoveryKey").textContent = "";
|
||||||
|
if (e.detail === "theseusid") refresh();
|
||||||
|
});
|
||||||
|
if (location.hash === "#theseusid") refresh();
|
||||||
|
})();
|
||||||
|
|
||||||
// ---- Add-ons management ----
|
// ---- Add-ons management ----
|
||||||
const addonsList = document.getElementById("addonsList");
|
const addonsList = document.getElementById("addonsList");
|
||||||
// Per-addon update state, keyed by addon id. Populated by loadAddonUpdates()
|
// Per-addon update state, keyed by addon id. Populated by loadAddonUpdates()
|
||||||
|
|
|
||||||
66
theseus-id-preload.js
Normal file
66
theseus-id-preload.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// window.theseusId — Theseus ID for web pages (DESIGN-theseus-id.md §5.1).
|
||||||
|
//
|
||||||
|
// const r = await theseusId.signIn({ projectId: "hephaestus", nonce });
|
||||||
|
// // r = { v, projectId, origin, account: "tid:q…", message, signature, scheme: "bip137", move? }
|
||||||
|
// // send { message, signature, move } to your server; verify with TheseusID/lib/verify.mjs
|
||||||
|
//
|
||||||
|
// Registered session-wide. The page passes fields, never message text;
|
||||||
|
// Theseus writes the message, takes the origin from the frame it committed,
|
||||||
|
// and answers only the top frame of a web tab. Failures reject with an Error
|
||||||
|
// whose `code` is one of: no-vault, locked, denied, origin-not-listed,
|
||||||
|
// origin-list-unavailable, bad-request, busy, not-top-frame, error.
|
||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
if (/^(https?|bns):$/.test(location.protocol)) {
|
||||||
|
// Errors lose custom properties crossing the bridge, so the code rides in
|
||||||
|
// the message as "[code] text" and is copied back onto the Error here, in
|
||||||
|
// the page's world.
|
||||||
|
const call = async (channel, payload) => {
|
||||||
|
const r = await ipcRenderer.invoke(channel, payload);
|
||||||
|
if (r && r.ok) return r.result;
|
||||||
|
const code = (r && r.error && r.error.code) || "error";
|
||||||
|
throw new Error(`[${code}] ${(r && r.error && r.error.message) || "Theseus ID failed"}`);
|
||||||
|
};
|
||||||
|
const str = (v, max) => (v == null ? undefined : String(v).slice(0, max));
|
||||||
|
// A locked vault is only unlocked for a page the user just clicked or
|
||||||
|
// typed in. navigator.userActivation is not enough: a page Theseus opens
|
||||||
|
// with loadURL starts out "activated", so it could pop the vault prompt on
|
||||||
|
// load. These listeners run in the preload's world and count only trusted
|
||||||
|
// events, which a page cannot synthesise.
|
||||||
|
let lastInput = 0;
|
||||||
|
for (const type of ["pointerdown", "keydown"]) {
|
||||||
|
window.addEventListener(type, (e) => { if (e.isTrusted) lastInput = Date.now(); }, true);
|
||||||
|
}
|
||||||
|
const recentInput = () => Date.now() - lastInput < 5000;
|
||||||
|
const api = {
|
||||||
|
version: 1,
|
||||||
|
signIn: (opts = {}) => call("theseus-id:signIn", {
|
||||||
|
projectId: str(opts.projectId, 300) ?? "",
|
||||||
|
nonce: str(opts.nonce, 200) ?? "",
|
||||||
|
statement: str(opts.statement, 200),
|
||||||
|
requestId: str(opts.requestId, 64),
|
||||||
|
resources: Array.isArray(opts.resources) ? opts.resources.slice(0, 8).map((x) => String(x).slice(0, 2048)) : undefined,
|
||||||
|
expiresIn: Number.isFinite(Number(opts.expiresIn)) ? Number(opts.expiresIn) : undefined,
|
||||||
|
// Read here, in the preload's world, so a page cannot claim a click it did not get.
|
||||||
|
gesture: recentInput() && !!(navigator.userActivation && navigator.userActivation.isActive),
|
||||||
|
}),
|
||||||
|
// After the project's server accepted a move proof (§6.3).
|
||||||
|
moved: (opts = {}) => call("theseus-id:moved", { projectId: str(opts.projectId, 300) ?? "", account: str(opts.account, 100) ?? "" }),
|
||||||
|
};
|
||||||
|
try { contextBridge.exposeInMainWorld("theseusIdBridge", api); } catch {}
|
||||||
|
// A thin wrapper in the page's own world turns "[code] text" into err.code.
|
||||||
|
try {
|
||||||
|
const { webFrame } = require("electron");
|
||||||
|
webFrame.executeJavaScript(`(() => {
|
||||||
|
const b = window.theseusIdBridge; if (!b || window.theseusId) return;
|
||||||
|
const wrap = (fn) => (...a) => fn(...a).catch((e) => {
|
||||||
|
const m = /^\\[([a-z-]+)\\] ([\\s\\S]*)$/.exec(String(e && e.message || ""));
|
||||||
|
const err = new Error(m ? m[2] : String(e && e.message || e));
|
||||||
|
err.code = m ? m[1] : "error";
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "theseusId", { value: Object.freeze({ version: b.version, signIn: wrap(b.signIn), moved: wrap(b.moved) }), enumerable: true });
|
||||||
|
try { delete window.theseusIdBridge; } catch {}
|
||||||
|
})()`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
64
unlock.html
64
unlock.html
|
|
@ -15,11 +15,11 @@
|
||||||
html, body { margin: 0; height: 100%; background: transparent; }
|
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); }
|
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);
|
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||||
display: grid; place-items: start center; padding-top: 48px; }
|
display: grid; place-items: center; padding: 16px; }
|
||||||
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||||
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
|
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
|
||||||
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
|
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
|
||||||
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
|
@keyframes pop { from { transform: scale(.97); 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 { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
|
||||||
.who .addon { color: var(--mut); }
|
.who .addon { color: var(--mut); }
|
||||||
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
|
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
|
||||||
|
|
@ -36,11 +36,15 @@
|
||||||
background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; }
|
background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; }
|
||||||
.pinpad .pinkeys button:hover { border-color: var(--acid); color: var(--acid); }
|
.pinpad .pinkeys button:hover { border-color: var(--acid); color: var(--acid); }
|
||||||
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
|
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
|
||||||
.pinpad .pinerr { color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; }
|
/* Two lines reserved, so a message appearing never changes the dialog's
|
||||||
|
height (it is centred, so any height change moves the whole pad). */
|
||||||
|
.pinpad .pinerr { color: var(--danger); font-size: 12px; line-height: 16px; text-align: center; min-height: 32px; max-width: 220px; }
|
||||||
|
.pinpad .pinkeys.busy { opacity: .55; pointer-events: none; }
|
||||||
|
.promptbox.still { animation: none; }
|
||||||
input[type=password] { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line);
|
input[type=password] { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line);
|
||||||
background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; }
|
background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; }
|
||||||
input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
|
input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
|
||||||
.err { color: var(--danger); font-size: 12.5px; min-height: 18px; margin-bottom: 6px; text-align: center; }
|
.err { color: var(--danger); font-size: 12.5px; line-height: 18px; min-height: 36px; margin-bottom: 6px; text-align: center; }
|
||||||
.pact { display: flex; gap: 6px; justify-content: space-between; align-items: 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; }
|
.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);
|
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
|
||||||
|
|
@ -67,6 +71,7 @@
|
||||||
let req = null;
|
let req = null;
|
||||||
let mode = "pin";
|
let mode = "pin";
|
||||||
let pin = "";
|
let pin = "";
|
||||||
|
let pinLength = 6; // 6-8; the PIN record says which, so the pad submits on the last digit
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let error = "";
|
let error = "";
|
||||||
|
|
||||||
|
|
@ -74,6 +79,7 @@
|
||||||
if (!req) return;
|
if (!req) return;
|
||||||
const id = req.reqId;
|
const id = req.reqId;
|
||||||
req = null;
|
req = null;
|
||||||
|
ui = null;
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
if (cancelled) window.unlock.cancel(id);
|
if (cancelled) window.unlock.cancel(id);
|
||||||
}
|
}
|
||||||
|
|
@ -81,67 +87,91 @@
|
||||||
async function submit(value) {
|
async function submit(value) {
|
||||||
if (!req || busy) return;
|
if (!req || busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
render();
|
paint();
|
||||||
const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message }));
|
const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message }));
|
||||||
busy = false;
|
busy = false;
|
||||||
if (r && r.ok) { req = null; document.body.replaceChildren(); return; }
|
if (r && r.ok) { req = null; ui = null; document.body.replaceChildren(); return; }
|
||||||
error = (r && r.error) || "Could not unlock";
|
error = (r && r.error) || "Could not unlock";
|
||||||
if (r && r.mode) mode = r.mode;
|
if (r && r.mode) mode = r.mode;
|
||||||
|
if (r && r.pinLength) pinLength = r.pinLength;
|
||||||
pin = "";
|
pin = "";
|
||||||
render();
|
paint(); // re-renders only if the answer switched mode or PIN length
|
||||||
}
|
}
|
||||||
|
|
||||||
function press(d) {
|
function press(d) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
if (d === "back") pin = pin.slice(0, -1);
|
if (d === "back") pin = pin.slice(0, -1);
|
||||||
else if (d === "clear") pin = "";
|
else if (d === "clear") pin = "";
|
||||||
else if (pin.length < 6) pin += d;
|
else if (pin.length < pinLength) pin += d;
|
||||||
error = "";
|
error = "";
|
||||||
render();
|
paint();
|
||||||
if (pin.length === 6) submit(pin);
|
if (pin.length === pinLength) submit(pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keypresses and answers update the dialog in place. Rebuilding it on
|
||||||
|
// every press replayed its pop-in animation and re-centred it whenever the
|
||||||
|
// message changed height, so the pad jumped under the user's finger.
|
||||||
|
let ui = null; // { mode, pinLength, dots[], err, go }
|
||||||
|
function paint() {
|
||||||
|
if (!req) return;
|
||||||
|
if (!ui || ui.mode !== mode || ui.pinLength !== pinLength) return render();
|
||||||
|
if (ui.dots) ui.dots.forEach((d, i) => d.classList.toggle("on", i < pin.length));
|
||||||
|
ui.err.textContent = error;
|
||||||
|
if (ui.pad) ui.pad.classList.toggle("busy", busy);
|
||||||
|
if (ui.go) { ui.go.textContent = busy ? "Checking…" : "Unlock"; ui.go.disabled = busy; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!req) return;
|
if (!req) return;
|
||||||
|
const replaying = !!ui; // a mode switch inside the same prompt: no second pop-in
|
||||||
|
ui = { mode, pinLength };
|
||||||
const head = [
|
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("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"),
|
||||||
|
el("span", {}, req.confirm ? "asks you to confirm it's you" : "asks to unlock your vault")),
|
||||||
el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"),
|
el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"),
|
||||||
req.reason ? el("p", { class: "reason" }, req.reason) : null,
|
req.reason ? el("p", { class: "reason" }, req.reason) : null,
|
||||||
];
|
];
|
||||||
let bodyEls;
|
let bodyEls;
|
||||||
if (mode === "pin") {
|
if (mode === "pin") {
|
||||||
const dots = el("div", { class: "pindots" }, ...Array.from({ length: 6 }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" })));
|
const dots = el("div", { class: "pindots" }, ...Array.from({ length: pinLength }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" })));
|
||||||
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
||||||
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
|
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
|
||||||
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
|
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
|
||||||
k === "back" ? "⌫" : k === "clear" ? "Clear" : k)));
|
k === "back" ? "⌫" : k === "clear" ? "Clear" : k)));
|
||||||
bodyEls = [el("div", { class: "pinpad" }, dots, pad, el("div", { class: "pinerr" }, error)),
|
ui.dots = [...dots.children];
|
||||||
|
ui.pad = pad;
|
||||||
|
ui.err = el("div", { class: "pinerr", role: "alert" }, error);
|
||||||
|
bodyEls = [el("div", { class: "pinpad" }, dots, pad, ui.err),
|
||||||
el("div", { class: "pact" },
|
el("div", { class: "pact" },
|
||||||
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"),
|
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; pin = ""; render(); } }, "Use master password"),
|
||||||
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
|
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
|
||||||
} else {
|
} else {
|
||||||
const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
|
const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
|
||||||
const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock");
|
const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock");
|
||||||
if (busy) go.setAttribute("disabled", "");
|
if (busy) go.setAttribute("disabled", "");
|
||||||
|
ui.go = go;
|
||||||
|
ui.err = el("div", { class: "err", role: "alert" }, error);
|
||||||
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
|
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
|
||||||
el("div", { class: "err" }, error),
|
ui.err,
|
||||||
el("div", { class: "pact" },
|
el("div", { class: "pact" },
|
||||||
req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; render(); } }, "Use PIN") : el("span"),
|
req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; pin = ""; render(); } }, "Use PIN") : el("span"),
|
||||||
el("div", { style: "display:flex;gap:6px" },
|
el("div", { style: "display:flex;gap:6px" },
|
||||||
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
||||||
bodyEls = [form];
|
bodyEls = [form];
|
||||||
setTimeout(() => input.focus(), 0);
|
setTimeout(() => input.focus(), 0);
|
||||||
}
|
}
|
||||||
document.body.replaceChildren(el("div", { class: "promptmask", onmousedown: (e) => { if (e.target.classList.contains("promptmask")) done(true); } },
|
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)));
|
el("div", { class: replaying ? "promptbox still" : "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
|
||||||
}
|
}
|
||||||
|
|
||||||
window.unlock.onShow((r) => {
|
window.unlock.onShow((r) => {
|
||||||
req = r;
|
req = r;
|
||||||
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
|
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
|
||||||
|
pinLength = r.pinLength || 6;
|
||||||
pin = "";
|
pin = "";
|
||||||
error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : "";
|
error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : "";
|
||||||
busy = false;
|
busy = false;
|
||||||
|
ui = null; // a new prompt: build it fresh, with its pop-in
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue