Compare commits

...

20 commits

Author SHA1 Message Date
Local Dev
c2bc27b0a1 Pithos 0.3.21: clearer Share dialog with pithos.sia/l links, restart prompt after in-place updates 2026-10-06 00:13:56 +02:00
Local Dev
62c2f269af Pithos 0.3.20: drive mode chip and Share, pithos.sia/app, Linux built on Hephaestus 2026-10-05 23:52:35 +02:00
Local Dev
1e45428dec Aegis: a BNS site and a web site of the same name are different sites
Wallet permissions were filed by the origin Theseus reports, which is
https://name for both registries. Whoever registered a colliding name in
BNS inherited the real site's connection and its silent-payment allowance,
and the other way round. A page served from BNS is now filed, and named on
every approval, as bns://name.

Grants saved before this stay under https://name: a saved connection only
discloses an address and everything else still prompts, so the BNS site of
that name simply asks again. Payment allowances are reset once, because
nobody can say which registry's site an old one was granted to.

Needs a host that reports the registry; older ones behave as before.
2026-10-05 23:40:16 +02:00
Local Dev
99a5923f42 Theseus: tell add-ons which name system served the page
A page message carried only an origin, and a bns:// page is shown as
https://. The same name can be one site on BNS and another on the ordinary
web (BNS carries registrations under ICANN TLDs on purpose), so an add-on
that files permissions by origin could not tell the two apart. The call
context now carries `registry: "bns" | "web"`, also across the sandbox
boundary, and api.features.pageRegistry says the host provides it.
2026-10-05 23:40:14 +02:00
Local Dev
7cc66ce680 Theseus: community extensions run in their own sandboxed process
An extension was require()d into the browser's main process. Its declared
capabilities bound only an honest one: there it could load electron, hook
the unlock prompt for the master password, read the vault files and the
wallet's store, call the OS keystore, and reach every tab.

Extensions that do not ship with Theseus now run in a separate process, in
Node mode under Node's permission model: read access to their own folder,
write access to a scratch folder, no child processes, no workers, no native
add-ons, no Electron, and none of the browser's memory. They reach the
browser only through an allow-listed message API that calls the same
functions, with the same capability checks, as before. Built-in add-ons are
unchanged and stay in-process.

For extension authors: host calls return promises (tabs.active included);
api.require / api.import are gone, so dependencies must be bundled;
registerRequestFilter and registerSiteRoute are not offered; the store is
handed over at start and written through, so storage.get stays synchronous.
An approval raised while handling a page message is still tied to that
page's tab. If the sandbox cannot start, the extension does not run.

The channel is JSON with tagged bytes: the binary IPC format depends on the
exact V8 build on both ends.
2026-10-05 22:54:26 +02:00
Local Dev
bf92548267 Aegis 0.32.2: no transaction is marked replaceable
Start of the next batch; 0.32.1 is published.

Bitcoin inputs were given the BIP125 sequence that opts in to
replace-by-fee. Aegis does not replace transactions on any chain and has
no way to bump one, so advertising a payment as replaceable only told the
recipient to distrust it until confirmed. Inputs are final again.
2026-10-05 22:54:25 +02:00
Local Dev
a1eceb444a Aegis 0.32.1: the PIN pads follow the vault PIN's length
Since Theseus 0.3.80 the vault PIN can be 6 to 8 digits, but Aegis's
pads still submitted at six, so anyone with a 7- or 8-digit PIN got
"wrong length" from every Aegis prompt and had to use the master
password. The lock-screen, transaction and reveal pads now draw as many
dots as the host reports (pinLength) and submit at that length; a
wrong length is explained instead of shown as a wrong PIN. Aegis's own
PIN on older hosts stays at six.
2026-10-05 22:43:05 +02:00
Local Dev
02d8331cbc Theseus 0.3.82: logins inside iframes, and a PIN pad with an Unlock button
- The password manager offers to save, and fills, logins in sign-in forms
  embedded in iframes (payment providers, single sign-on widgets), keyed to
  the frame's own site.
- PIN pads: Unlock (or Enter) beside Cancel instead of submitting on the
  last digit; a new PIN is any 6 to 8 digits with Next; larger keys; calmer
  hover so the pad no longer seems to flash as you type.
2026-10-05 21:00:07 +02:00
Local Dev
c4b3e77238 PIN pads: an Unlock button, Next to set 6-8 digits, bigger keys
A PIN can be 6, 7 or 8 digits, but the pads submitted on the last expected
digit, and setting one meant first picking its length. Now:
- The unlock prompt fills the dots for the PIN's length and sends it with
  Unlock (or Enter) beside Cancel, so a slip can still be fixed with the
  delete key first.
- Setting a PIN takes 6 to 8 digits with Next (the last two dots are marked
  optional), and the repeat step saves with Save PIN once it has as many.
- Keys are larger (76x56, 24px digits) and the dots a little bigger.
2026-10-05 20:59:25 +02:00
Local Dev
ed441b4e9d Passwords: save and fill logins inside iframes too
Sign-in widgets and embedded checkouts often put the login form in an
iframe, and the password hooks only ran in a tab's top frame, so those
logins were never offered for saving or filling.

- Web tabs now run preloads in iframes (nodeIntegrationInSubFrames; pages
  still get no Node). Only the password hooks act there: the home-page
  bridge, window.bcnr, add-on page scripts and window.theseusId return early
  outside the top frame, exactly as before.
- A login in a frame belongs to the frame's own site, taken from that
  frame's committed URL. The save prompt says "login.example (in a frame on
  shop.example)", the fill offer names both, and the fill goes into that
  exact frame only while it is still that tab's and still on that site.
- The "did the login go through" check runs against the frame.

Verified with a shop page embedding a cross-site login frame: save offer,
fill offer and fill all target the frame; the outer page gets nothing;
top-frame logins behave as before.
2026-10-05 20:34:17 +02:00
Local Dev
b4362f2e54 PIN pads: calm hover and press states
Moving the pointer across the keypad lit each key it crossed with a bright
acid border and digit, and every filled dot glowed, which read as the pad
flashing while a PIN was typed. Keys now only lighten slightly on hover and
press in a little when clicked; dots fill without the glow.
2026-10-05 20:28:20 +02:00
Local Dev
877137d8c2 Theseus 0.3.81: the Settings menu runs full height again
0.3.80 shipped with the Settings left menu squeezed into a short centred
box (a .app class clash from the Apps list, fixed in 882b6fb). This is
0.3.80 plus that fix.
2026-10-05 20:15:45 +02:00
Local Dev
869b94a85a Merge release/0.3.80: Theseus ID, guided PIN, save-login offers 2026-10-05 20:15:33 +02:00
Local Dev
e598fdf1e6 Theseus Settings: the menu runs full height again
The Apps list styled its rows with .app, the class the Settings page itself
uses for its layout. The later rule won on the page too: align-items:center
pulled the left menu into a short box in the middle of the window, with a
border, padding and rounded corners it never had. The rows are .webapp now.
2026-10-05 20:14:16 +02:00
Local Dev
926adea764 Theseus 0.3.80: Theseus ID, a guided PIN, and logins that offer to save
- Theseus ID: window.theseusId.signIn lets Silent Mode projects (and sites
  whose own origin list allows it) sign the user in with an ID derived from
  the vault: private per project by default, or one shared ID. Settings ›
  Theseus ID lists the projects, changes or renews an ID, revokes, and shows
  the recovery key. The login service is https://id.theseus.x.
- BNS sites on an ip record get the page's own POSTs, headers and cookies,
  so forms and sign-ins on hephaestus.x and id.theseus.x work.
- Vault: PINs of 6 to 8 digits set up step by step on a keypad; the unlock
  prompt is centred and its keypad no longer jumps under the cursor.
- Logins: after a sign-in or sign-up, Theseus offers to save the password
  (optionally behind a PIN check), offers saved logins on login forms, and
  keeps sign-ins for vault sites when cookies are cleared on quit.
2026-10-05 20:04:41 +02:00
Local Dev
8db0a2f9f9 Merge branch 'claude/focused-visvesvaraya-177d2e' into release/0.3.80
# Conflicts:
#	TheseusNavigator/main.js
#	TheseusNavigator/settings.html
2026-10-05 20:04:12 +02:00
Local Dev
225a9e261e BNS ip-record sites: forward the page's method, body and headers
Theseus fetched pinned ip-record sites with a bare GET whatever the page
asked for, so forms and JSON POSTs on sites like hephaestus.x or
id.theseus.x never reached the server, and redirects and cookies were
dropped on the way back. Requests now carry the method, body and the
headers a site needs (content type, auth, its own cookies, x-* headers),
with the page's bns:// origin presented as https://<name>, the mapping
Theseus already uses for that origin; responses keep Location and
Set-Cookie.
2026-10-04 21:48:07 +02:00
Local Dev
b8529f4a7f PIN pads: stop the keypad jumping on every press
The vault prompt rebuilt its whole dialog on each digit, which replayed the
pop-in animation, and because the dialog is centred, every change in the
message line's height moved the whole pad under the user's finger. Presses
and answers now update the dots and message in place, the message lines
keep a fixed two-line height, and switching between PIN and password clears
a half-typed PIN. The Settings PIN dialog keeps the 6/7/8 row's space on the
repeat step so the pad stays put between steps. Measured on a scratch
profile: key positions identical across presses, errors and steps.
2026-10-04 21:18:26 +02:00
Local Dev
3243add70e Theseus ID in Theseus: window.theseusId.signIn and Settings › Theseus ID
Pages of Silent Mode projects can now sign the user in with their Theseus
ID instead of a wallet phrase typed into the page. Theseus writes the
sign-in message itself, takes the origin from the committed top frame, and
signs as a project only on an origin that project's list includes, so a
phishing page cannot get another project's signature and no page can use
the ID key to sign anything else.

- lib/theseus-id.cjs: the policy (first sign-in always asks and lets the
  user pick a private or One ID; Silent Mode projects are silent after
  that while the vault is open; per-site "always"; 10 silent signatures per
  minute per origin), the per-project record encrypted under a key derived
  from the vault, origin-list fetching with a 1 h cache and a 7-day stale
  fallback, and ID moves that send a proof signed by both keys and only
  finish once the project confirms.
- A locked vault is unlocked only for a page the user just clicked or typed
  in: navigator.userActivation alone is true on load for pages opened with
  loadURL, which would let a page pop the vault prompt by itself.
- Settings › Theseus ID: default mode, One ID, automatic sign-in toggle,
  signed-in projects (always, change ID, new ID, revoke) and a recovery key
  behind a fresh PIN / password check.
- TheseusID/registry/projects.json is the first-party list (Hephaestus,
  Sirius, Pithos); it and TheseusID/lib ship as extraResources.
- Token-aware cashaddrs (BNS owners) now decode for owner-signed lists.

Verified on a scratch profile against a local test project whose server
checks signatures with TheseusID/lib/verify.mjs: locked vault on load gives
"locked" with no prompt, first sign-in prompt, silent second sign-in, a
claimed foreign project refused without a prompt, an ID move that keeps the
project's account, and the recovery key behind the confirm prompt.
2026-10-04 20:48:07 +02:00
Local Dev
a6f7b335a5 Vault: PIN setup steps, 6-8 digit PINs, save and offer logins, keep sign-ins
The PIN could only be six digits and was set from three bare inputs; the
unlock prompt sat at the top of the page; and the password manager only
filled when you found the key chip, never offered to save, and "Clear
cookies on quit" signed you out of every site, including the ones whose
login the vault already holds.

- PINs are 6 to 8 digits. The PIN record stores its length so pads draw the
  right number of dots and submit on the last digit; a PIN of the wrong
  length is refused without a strike, so an older Aegis pad cannot burn the
  count against an 8-digit PIN.
- Settings sets a PIN in steps: master password, choose the PIN on a pad
  (6/7/8), repeat it, done. The locked vault opens Theseus's own prompt,
  which is now centred, with the PIN pad or the master password field.
- After a sign-in or sign-up form is sent and the page moves on, Theseus
  offers to save (or update) the login, with an optional "ask for my PIN or
  password before filling it". Focusing a login form offers the saved
  logins under it; on a locked vault it offers to unlock first. A failed
  login (the password field still showing) gets no offer.
- "Keep sign-ins for sites in your vault" (on): the quit clear spares the
  cookies and site storage of sites with a saved login. Their hostnames are
  kept sealed with the OS keystore so the list is readable at quit while
  the vault is locked. Verified end to end on a scratch profile: signed in,
  restarted, still signed in; another site's cookie was cleared.
2026-10-04 20:23:43 +02:00
29 changed files with 2402 additions and 186 deletions

View file

@ -15,6 +15,12 @@
// ordinary preload idiom keep working.
const { contextBridge, ipcRenderer, webFrame } = require("electron");
// Web tabs run preloads in iframes too (nodeIntegrationInSubFrames, for the
// password hooks). Add-on page scripts (wallet providers, consent rules)
// stay top-frame only, as they always were: main decides them from the
// tab's URL, not the frame's, and must never put them into third-party frames.
if (window.top !== window) return;
let injections = [];
try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; }
catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); }

View file

@ -30,6 +30,46 @@ const path = require("node:path");
// How long a call waits for an async activate() to settle before it is
// delivered anyway.
const READY_WAIT_MS = 5000;
// What a sandboxed (community) extension may ask of the host. Each name is a
// path on the api object _makeApi builds, so its capability check still runs.
// Absent on purpose: require/import (host modules), registerRequestFilter and
// registerSiteRoute (take a function), vault.imports / vault.pin and the
// lifecycle calls that handle the master password, and storage (the store is
// handed over at start and written through its own message).
const SANDBOX_API = new Set([
"emit", "registerSidebarPanel", "revealSidebar", "openTab", "openSettings", "setSessionProxy",
"captureTab", "saveCapture", "scanActiveTabForUris", "approvalModal",
"checkAndStageSelfUpdate", "applySelfUpdate", "restartApp", "startAtLaunch", "whenUiReady",
"tabs.active", "vault.derive", "vault.requestUnlock", "vault.lifecycle.status",
]);
// Messages cross the process boundary as JSON (the binary 'advanced' channel
// format is tied to the exact V8 build on both ends). Bytes and BigInts are
// tagged so they arrive as what they were. Same pair as in the runner.
function wireEnc(v, depth = 0) {
if (depth > 40) throw new Error("value too deeply nested");
if (v === null || v === undefined) return v === undefined ? undefined : null;
if (typeof v === "bigint") return { __wire: "big", v: v.toString() };
if (typeof v === "function" || typeof v === "symbol") return undefined;
if (typeof v !== "object") return v;
if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") };
if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") };
if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; });
if (v instanceof Date) return v.toISOString();
const out = {};
for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; }
return out;
}
function wireDec(v) {
if (v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map(wireDec);
if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64"));
if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v);
const out = {};
for (const k of Object.keys(v)) out[k] = wireDec(v[k]);
return out;
}
const SANDBOX_CALL_MS = 120000;
const SANDBOX_STORE_VALUE_MAX = 4 * 1024 * 1024;
// Extension points the framework understands. Extending this list means also
// teaching main.js and (typically) the chrome renderer about the new point.
@ -323,6 +363,10 @@ class AddonHost {
// isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus,
// and which legacy ids those absorb. Gate `absorbs` in vault.derive.
this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null;
// Extensions that are not built in run in a sandboxed process unless the
// host explicitly opts out (sandboxExecPath overrides the binary; tests).
this._sandboxCommunity = arguments[0].sandboxCommunity !== false;
this._sandboxExecPath = typeof arguments[0].sandboxExecPath === "string" ? arguments[0].sandboxExecPath : null;
this._isReservedId = typeof arguments[0].isReservedId === "function" ? arguments[0].isReservedId : null;
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
@ -589,7 +633,10 @@ class AddonHost {
// resolver. This is where the trust decision lives: we're loading arbitrary
// JS into the main process with full API access.
let mod;
try {
const holder = { active: null };
const sandboxed = this._shouldSandbox(manifest);
if (sandboxed) mod = this._sandboxModule(manifest, folder, mainPath, holder);
else try {
// Bust the require cache so a manual reload picks up edits — cheap since
// add-ons are small. When the version changed (a hot-applied update) the
// whole folder goes, or the new index.js would run against the previous
@ -608,7 +655,8 @@ class AddonHost {
throw new Error(`main file must export an activate(api) function`);
}
// Declared panels are registered up front; activate() may still add more.
const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() };
const active = { manifest, folder, exports: mod, sandboxed, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() };
holder.active = active;
if (manifest.pageInject) {
// Read the inject source once at activation. It's shipped to every
// matching tab's preload verbatim, so a syntax error surfaces in the
@ -644,6 +692,139 @@ class AddonHost {
this.log(`activated ${manifest.id} v${manifest.version}`);
}
// ---- sandbox for extensions that do not ship with Theseus -----------------
// A built-in add-on is code we publish and it runs in this process. Anything
// else — a community extension from the catalog — runs in its own process
// under Node's permission model (see lib/addon-sandbox-runner.cjs) and
// reaches the browser only through the allow-listed calls below. In this
// process an extension could load `electron`, watch the unlock prompt, and
// read the vault, the wallet's store and every tab, whatever its manifest
// said. If the sandbox cannot be started the extension does not run.
_shouldSandbox(manifest) {
return this._sandboxCommunity && !!this._isFirstPartyId && !this._isFirstPartyId(manifest.id);
}
_sandboxRunnerFile() {
if (this._runnerFile) return this._runnerFile;
// Copied out to a real file each launch: the packaged app keeps lib/
// inside app.asar, which a process in Node mode under the permission
// model cannot be pointed at.
const src = fs.readFileSync(path.join(__dirname, "lib", "addon-sandbox-runner.cjs"), "utf8");
const dir = path.join(this.dataDir, ".sandbox");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "runner.cjs");
fs.writeFileSync(file, src);
return (this._runnerFile = file);
}
_sandboxModule(manifest, folder, mainPath, holder) {
const { fork } = require("node:child_process");
const id = manifest.id;
const log = (...a) => this.log(`[${id}]`, ...a);
let child = null, nextId = 0, activated = null;
const waiting = new Map(); // call id -> { resolve, reject, timer }
const callScopes = new Map(); // call id -> pageCallCtx store while a page call is in flight
const failAll = (why) => { for (const w of waiting.values()) { clearTimeout(w.timer); w.reject(new Error(why)); } waiting.clear(); callScopes.clear(); };
const send = (m) => { try { if (child && child.connected) child.send(wireEnc(m)); } catch {} };
const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined });
const activate = (api) => new Promise((resolve, reject) => {
const runner = this._sandboxRunnerFile();
const scratch = path.join(this.dataDir, ".sandbox", "scratch", id);
fs.mkdirSync(scratch, { recursive: true });
const env = { ELECTRON_RUN_AS_NODE: "1" };
for (const k of ["SystemRoot", "windir", "TEMP", "TMP", "LANG", "TZ"]) if (process.env[k]) env[k] = process.env[k];
try {
child = fork(runner, [], {
execPath: this._sandboxExecPath || process.execPath,
execArgv: ["--permission", `--allow-fs-read=${runner}`, `--allow-fs-read=${folder}`, `--allow-fs-read=${scratch}`, `--allow-fs-write=${scratch}`],
cwd: folder, env, serialization: "json", windowsHide: true,
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
} catch (e) { reject(new Error(`sandbox could not start: ${e?.message || e}`)); return; }
activated = { resolve, reject };
const startTimer = setTimeout(() => { if (activated) { activated = null; try { child.kill(); } catch {} reject(new Error("sandbox did not answer")); } }, 20000);
if (startTimer.unref) startTimer.unref();
let tabsOff = null;
child.on("exit", (code) => {
clearTimeout(startTimer);
try { if (tabsOff) tabsOff(); } catch {}
failAll("the extension's process ended");
if (activated) { activated.reject(new Error(`sandbox exited before activation (code ${code})`)); activated = null; }
else if (holder.active && this._active.get(id) === holder.active) log(`sandboxed process exited (code ${code})`);
});
child.on("error", (e) => log("sandbox error:", e?.message || e));
child.on("message", async (raw) => {
if (!raw || typeof raw !== "object") return;
const m = wireDec(raw);
const active = holder.active;
if (m.t === "ready") {
let store = {};
try { store = api.storage.all() || {}; } catch {}
send({ t: "activate", manifest: { id, name: manifest.name, version: manifest.version, capabilities: manifest.capabilities }, folder, mainPath, scratchDir: scratch, store, features: api.features });
} else if (m.t === "activated") {
clearTimeout(startTimer);
const a = activated; activated = null;
if (!a) return;
if (m.ok) a.resolve(); else a.reject(new Error(m.error?.message || "activate() failed"));
} else if (m.t === "handler") {
if (!active || typeof m.name !== "string" || !m.name) return;
// A stub in this process forwards each call to the handler that
// lives in the extension's process.
active.handlers.set(m.name, (payload, ctx) => new Promise((res, rej) => {
const cid = ++nextId;
const scope = pageCallCtx.getStore();
if (scope) callScopes.set(cid, scope);
const timer = setTimeout(() => { waiting.delete(cid); callScopes.delete(cid); rej(new Error(`"${m.name}" timed out`)); }, SANDBOX_CALL_MS);
if (timer.unref) timer.unref();
waiting.set(cid, { resolve: res, reject: rej, timer });
send({ t: "call", id: cid, name: m.name, payload, ctx: ctx ? { from: ctx.from, origin: ctx.origin, tabId: ctx.tabId, registry: ctx.registry } : {} });
}));
} else if (m.t === "res") {
const w = waiting.get(m.id);
if (!w) return;
waiting.delete(m.id); callScopes.delete(m.id); clearTimeout(w.timer);
if (m.ok) w.resolve(m.value);
else { const e = new Error(m.error?.message || "extension call failed"); if (m.error?.code != null) e.code = m.error.code; w.reject(e); }
} else if (m.t === "api") {
// The only way the extension reaches the browser. Not on the list:
// not callable. On the list: the same function, with the same
// capability checks, an in-process add-on would have called.
let out;
try {
if (typeof m.path !== "string" || !SANDBOX_API.has(m.path)) throw new Error(`"${m.path}" is not available to sandboxed extensions`);
const fn = m.path.split(".").reduce((o, k) => (o ? o[k] : undefined), api);
if (typeof fn !== "function") throw new Error(`"${m.path}" is not available`);
const args = Array.isArray(m.args) ? m.args : [];
const scope = m.callId != null ? callScopes.get(m.callId) : null;
const value = await (scope ? pageCallCtx.run(scope, () => fn(...args)) : fn(...args));
out = { t: "res", id: m.id, ok: true, value: value === undefined ? null : value };
} catch (e) { out = { t: "res", id: m.id, ok: false, error: errOut(e) }; }
try { send(out); } catch { send({ t: "res", id: m.id, ok: false, error: { message: "result could not be passed to the extension" } }); }
} else if (m.t === "storage.set") {
try {
const size = m.value == null ? 0 : JSON.stringify(m.value).length;
if (size > SANDBOX_STORE_VALUE_MAX) throw new Error("value too large");
if (String(m.key).startsWith("__") && m.key !== "__pageInjectPolicy") throw new Error("reserved key");
api.storage.set(String(m.key), m.value == null ? null : m.value);
} catch (e) { log(`storage.set("${m.key}") refused: ${e?.message || e}`); }
} else if (m.t === "log") {
log(...(Array.isArray(m.args) ? m.args.map((x) => String(x).slice(0, 2000)) : []));
} else if (m.t === "tabs.watch") {
if (tabsOff) return;
try { tabsOff = api.tabs.onChange((t) => send({ t: "tabs", data: t })); } catch (e) { log("tabs.onChange refused:", e?.message || e); }
}
});
});
const deactivate = () => {
failAll("the extension was stopped");
const c = child; child = null;
if (!c) return;
try { if (c.connected) c.send(wireEnc({ t: "deactivate" })); } catch {}
const t = setTimeout(() => { try { c.kill(); } catch {} }, 1500);
if (t.unref) t.unref();
};
return { activate, deactivate };
}
// Tear down every active add-on before a re-discover so long-lived state
// (sockets, timers) from a previous activation doesn't pile up.
_deactivateAll() {
@ -662,7 +843,7 @@ class AddonHost {
return {
// What this host can do beyond the documented surface, so an add-on
// can tell "the user switched it off" from "this Theseus is too old".
features: Object.freeze({ pageInjectPolicy: true, vaultPin: !!this._vaultPin }),
features: Object.freeze({ pageInjectPolicy: true, vaultPin: !!this._vaultPin, pageRegistry: true }),
// Metadata the add-on may want to reflect on
id: manifest.id,
folder,

View file

@ -75,7 +75,9 @@
const hasCancel = actions.some((a) => a.id === "cancel");
document.body.innerHTML =
`<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>
${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>` : ""}

View file

@ -11,6 +11,11 @@
// API surface.
const { contextBridge, ipcRenderer } = require("electron");
// Web tabs run preloads in iframes too (nodeIntegrationInSubFrames, for the
// password hooks in home-preload.js). This one stays top-frame only, as
// it always was.
if (window.top !== window) return;
// Every method returns a Promise; a name that fails to resolve or isn't
// registered comes back as `null` (not an error) so page code can treat
// "no such name" as data, not an exception. `getBcnrTlds` always returns

View file

@ -1,7 +1,7 @@
{
"id": "aegis",
"name": "Aegis Wallet",
"version": "0.32.0",
"version": "0.32.2",
"category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.",
"author": "Silent Mode",

View file

@ -1495,8 +1495,17 @@ function plainLabel(v, max = 80) {
return String(v ?? "").replace(INVISIBLE_RE, "").replace(/\s+/g, " ").trim().slice(0, max);
}
function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); }
// The origin a page's permissions are filed under, and what the approval
// overlay names. A page served from BNS and a page served from the ordinary
// web are different sites even when the name is the same — Theseus reports
// both as "https://name", and BNS carries registrations under ICANN TLDs on
// purpose, so whoever registers a colliding name in BNS used to inherit the
// real site's wallet connection and its silent-payment allowance (and the
// other way round). A BNS page is filed under "bns://name" instead. Hosts
// that do not say which registry served the page keep the old behaviour.
function fromPage(m) {
if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message");
if (m.registry === "bns") return String(m.origin).replace(/^https?:\/\//i, "bns://");
return m.origin;
}
@ -2770,6 +2779,7 @@ function registerPanelMessages(api) {
syncInjectPolicy(api);
return injectState();
});
try { migrateRegistryOrigins(api); } catch {}
try { syncInjectPolicy(api); } catch {}
api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); });
@ -2856,6 +2866,11 @@ function registerPanelMessages(api) {
await refreshHostPin(api);
if (r && r.ok) return { ok: true, masterPassword: mintPinProof() };
if (r && r.code === "no-pin") throw new Error("no PIN is set");
// Not a guess (Theseus spends no strike on it): the pad had the wrong
// number of digits for this PIN.
if (r && r.code === "wrong-length") {
return { ok: false, remaining: Number(r.remaining) || 0, lockedMs: 0, error: `Your PIN has ${Number(r.length) || hostPinCache.length || "a different number of"} digits. Reopen this prompt and try again.` };
}
return { ok: false, remaining: Number(r?.remaining) || 0, lockedMs: Number(r?.lockedMs) || 0, error: r?.error || undefined };
}
const blob = openPinBlob(api);
@ -2981,6 +2996,7 @@ function registerPanelMessages(api) {
pinHardware: blob ? (blob.hw ? "tpm" : "none") : host ? (host.pinSet ? host.hardware || "none" : null) : null,
pinStorable: host ? host.storable !== false : osSealUsable(safeStorageOr(api)),
pinUnified: !!hostPinApi(api),
pinLength: host && host.pinSet && Number(host.length) >= 6 && Number(host.length) <= 8 ? Number(host.length) : 6,
pinLegacyExposure: exposed && !exposed.dismissed ? { at: exposed.at } : null,
};
}
@ -3359,9 +3375,28 @@ function injectAllowList(api) {
}
function injectMode(api) { return api.storage.get(INJECT_MODE_KEY, "all") === "allowed" ? "allowed" : "all"; }
function syncInjectPolicy(api) {
const origins = Array.from(new Set([...injectAllowList(api), ...Object.keys(permissions(api))]));
// Visibility is by host name: the host matches a bns:// page against the
// https form, so a grant filed under "bns://name" is listed as that.
const origins = Array.from(new Set([...injectAllowList(api), ...Object.keys(permissions(api))].map(normalizeOrigin).filter(Boolean)));
api.storage.set("__pageInjectPolicy", { mode: injectMode(api), origins });
}
// Once, when the host first tells BNS pages apart: grants saved before then
// are filed under "https://name" and may have been given to either registry's
// site. A saved connection only discloses an address and still prompts for
// everything else, so those stay (the BNS site of that name simply asks
// again). A payment allowance spends without a prompt — those are dropped,
// because nobody can say which site it was really granted to.
function migrateRegistryOrigins(api) {
if (!(api.features && api.features.pageRegistry)) return;
if (api.storage.get("aegis/migrations/registryOrigins", false)) return;
const perms = permissions(api);
let dropped = 0;
for (const o of Object.keys(perms)) {
if (perms[o] && perms[o].sendTx) { delete perms[o].sendTx; dropped++; }
}
if (dropped) { api.storage.set("permissions", perms); api.log(`registry-separated origins: ${dropped} payment allowance(s) reset`); }
api.storage.set("aegis/migrations/registryOrigins", { at: Date.now(), dropped });
}
function normalizeOrigin(raw) {
try {
const u = new URL(String(raw || "").replace(/^bns:\/\//i, "https://"));
@ -3765,7 +3800,7 @@ async function withOriginLock(origin, fn) {
// protection is downstream and unchanged: an approval overlay on every
// action plus per-origin permissions. This only keeps non-web schemes out.
function isDappOrigin(origin) {
try { const u = new URL(origin); return u.protocol === "https:" || u.protocol === "http:"; }
try { const u = new URL(origin); return u.protocol === "https:" || u.protocol === "http:" || u.protocol === "bns:"; }
catch { return false; }
}
function legacyBchRuntime() {

View file

@ -193,7 +193,6 @@ module.exports = function makeBtcAdapter({
// number of vbytes, and paying for 152.5 of 153 lands under the rate.
Math.ceil(Math.ceil(OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + outScripts.reduce((a, s) => a + 9 + s.length, 0)) * feePerVb);
const DEFAULT_RATE = 5; // sat/vB when the server has no estimate
const RBF_SEQUENCE = 0xfffffffd; // BIP125: opt in to replace-by-fee
class BtcWallet {
constructor(root32, networkId, {
@ -460,9 +459,10 @@ module.exports = function makeBtcAdapter({
const prevHex = await verifyFunding({ chosen: plan._chosen, client: this._client, Transaction: bitcoinjs.Transaction });
const psbt = new Psbt({ network: this._bjsNet });
for (const u of plan._chosen) {
// Signal replace-by-fee, so a payment sent at too low a rate can be
// re-issued at a higher one instead of sitting unconfirmed for days.
const inp = { hash: u.txid, index: u.vout, sequence: RBF_SEQUENCE };
// Final sequence on every input: Aegis does not do replace-by-fee on
// any chain, so it must not advertise a transaction as replaceable
// (a recipient would then be right to distrust it until confirmed).
const inp = { hash: u.txid, index: u.vout, sequence: 0xffffffff };
const fam = u.entry.family;
if (fam === "bip44") {
inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex");

View file

@ -365,7 +365,7 @@ module.exports = function makeDgbAdapter({
// ---- plan + sign (via @dgb-wallet/psbt) -----------------------------
// sat/vB from the Electrum server, never below the 20 this wallet has
// always paid (DigiByte's relay floor is far above Bitcoin's, and there
// is no replace-by-fee on DGB to rescue an underpaid transaction).
// is nothing to rescue an underpaid transaction: Aegis never replaces one).
async _estimateRate() {
if (this._feeEst && Date.now() - this._feeEstAt < 60_000) return this._feeEst;
try {

View file

@ -330,6 +330,12 @@ const pinTry = async (pin) => {
async function pinLockoutRemainingMs() {
try { return Number((await S.invoke("pinStatus")).lockedMs) || 0; } catch { return 0; }
}
// How many digits the PIN being entered has: the Theseus vault PIN can be
// 6-8 digits (pinLength from the host), Aegis's own PIN is always 6.
function pinEntryLength() {
const n = Number(securityState && securityState.pinLength);
return n >= 6 && n <= 8 ? n : 6;
}
async function refreshSecurityState() {
try {
securityState = await S.invoke("securityGet");
@ -3375,7 +3381,7 @@ function renderLockScreen(phase) {
body.dataset.mode = "pin";
body.innerHTML = `
<div class="pinpad" id="lockPinPad">
<div class="pindots" id="lockPinDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pindots" id="lockPinDots">${"<span class=\"pindot\"></span>".repeat(pinEntryLength())}</div>
<div class="pinkeys" id="lockPinKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
@ -3469,7 +3475,10 @@ function renderLockScreen(phase) {
// are duplicate ids — a global lookup then returns the FIRST one, and this
// function binds a second click handler to the wrong pad's buttons. The
// symptom is a pad that appends two digits per press and resets after three.
// The pad takes its length from the dots it was drawn with: 6 for Aegis's
// own PIN, 6-8 for the Theseus vault PIN (pinLength from the host).
function setupPinPad({ dots, keys, err, onComplete }) {
const len = dots.querySelectorAll(".pindot").length || 6;
let buf = "";
const paint = () => {
const nodes = dots.querySelectorAll(".pindot");
@ -3480,10 +3489,10 @@ function setupPinPad({ dots, keys, err, onComplete }) {
if (err) err.textContent = "";
if (k === "clear") { buf = ""; paint(); return; }
if (k === "back") { buf = buf.slice(0, -1); paint(); return; }
if (buf.length >= 6) return;
if (buf.length >= len) return;
buf += k;
paint();
if (buf.length === 6) {
if (buf.length === len) {
keys.querySelectorAll("button").forEach((x) => x.disabled = true);
let res = "reset";
try { res = await onComplete(buf); }
@ -3512,9 +3521,9 @@ function setupPinPad({ dots, keys, err, onComplete }) {
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
if (err) err.textContent = "";
if (/^[0-9]$/.test(e.key)) {
if (buf.length >= 6) return;
if (buf.length >= len) return;
buf += e.key; paint();
if (buf.length === 6) {
if (buf.length === len) {
keys.querySelectorAll("button").forEach((x) => x.disabled = true);
let res = "reset";
try { res = await onComplete(buf); }
@ -5345,7 +5354,7 @@ function capturePinForSecret(subtitle) {
<h2>Confirm with PIN</h2>
<div class="pinsub" id="rsSub">${esc(subtitle || "")}</div>
<div class="pinpad">
<div class="pindots" id="rsDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pindots" id="rsDots">${"<span class=\"pindot\"></span>".repeat(pinEntryLength())}</div>
<div class="pinkeys" id="rsKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>
@ -5410,7 +5419,7 @@ async function verifyPinInteractivelyOnce(subtitle) {
<h2>Confirm with PIN</h2>
<div class="pinsub" id="vpSub">${esc(subtitle || "")}</div>
<div class="pinpad">
<div class="pindots" id="vpDots">${"<span class=\"pindot\"></span>".repeat(6)}</div>
<div class="pindots" id="vpDots">${"<span class=\"pindot\"></span>".repeat(pinEntryLength())}</div>
<div class="pinkeys" id="vpKeys">
${[1,2,3,4,5,6,7,8,9].map((n) => `<button data-k="${n}">${n}</button>`).join("")}
<button class="util" data-k="clear">Clear</button>

View file

@ -1,7 +1,7 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.3.19",
"version": "0.3.21",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",

View file

@ -42,6 +42,15 @@ class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
function readOwnVersion() {
for (const f of ['../addon.json', '../package.json']) {
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {}
}
return null;
}
const LOADED_VERSION = readOwnVersion();
export async function createPithos(opts = {}) {
const {
host = '127.0.0.1',
@ -90,13 +99,11 @@ export async function createPithos(opts = {}) {
const tickets = new Map();
const subscribers = new Set();
let versionCache = null;
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
const pithosVersion = (() => {
for (const f of ['../addon.json', '../package.json']) {
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {}
}
return null;
})();
// The version of the code running (read when this module loaded) and the one
// on disk now. Theseus can swap an add-on's files in place, but Node keeps
// the modules it already loaded, so they differ until Theseus restarts.
const pithosVersion = LOADED_VERSION;
const installedVersion = readOwnVersion();
let hosted = null; // "On Silent Mode", installed below
const broadcast = (event, data) => {
@ -182,6 +189,8 @@ export async function createPithos(opts = {}) {
const local = {
host: hostName,
pithos: pithosVersion,
// differs from pithos after an in-place update until the host restarts
installed: readOwnVersion() || installedVersion,
// electron-builder's portable exe sets this; updates then fetch the portable build.
portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE,
platform: process.platform,
@ -512,8 +521,9 @@ export async function createPithos(opts = {}) {
const { key, prefix, days = 365 } = await jsonBody(req);
const until = Math.floor(Date.now() / 1000) + Math.min(3650, Math.max(1, Number(days) || 365)) * 86400;
let body;
if (prefix) {
if (!prefix.endsWith('/')) throw new HttpError(400, 'a folder ends with /');
// prefix "" (with no key) is the drive's top level
if (prefix != null && !key) {
if (prefix && !prefix.endsWith('/')) throw new HttpError(400, 'a folder ends with /');
const s3 = await s3For(p.user);
const files = [];
let token;
@ -525,7 +535,7 @@ export async function createPithos(opts = {}) {
if (!files.length) throw new HttpError(400, 'This folder has no files to share (files in sub-folders are not included).');
const out = [];
for (const k of files) out.push({ name: k.slice(prefix.length), url: (await shareUrlFor(p.bucket, k, until)).url });
body = { name: prefix.slice(0, -1).split('/').pop(), folder: true, files: out };
body = { name: prefix ? prefix.slice(0, -1).split('/').pop() : p.bucket, folder: true, files: out };
} else {
if (!key || key.endsWith('/')) throw new HttpError(400, 'a file is required');
body = { name: key.split('/').pop(), url: (await shareUrlFor(p.bucket, key, until)).url };
@ -534,7 +544,7 @@ export async function createPithos(opts = {}) {
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`);
const list = readSiaLinks();
list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: prefix || null, until, created: Date.now(), files: body.files ? body.files.length : 1 });
list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: key ? null : (prefix ?? null), until, created: Date.now(), files: body.files ? body.files.length : 1 });
writeSiaLinks(list);
return { id: out.id, url: out.url, until };
});
@ -960,6 +970,7 @@ export function handoffRoute(p) {
if (!segs.length || segs.some((x) => !/^[A-Za-z0-9._~%=-]{1,120}$/.test(x))) return '#/overview';
const pages = ['overview', 'users', 'buckets', 'account', 'settings', 'logs'];
if (segs[0] === 'drives') return '#/buckets';
if (segs[0] === 'app') return segs.length > 1 ? handoffRoute('/' + segs.slice(1).join('/')) : '#/overview';
if (pages.includes(segs[0])) return `#/${segs.join('/')}`;
return `#/u/${segs.join('/')}`; // /<user>/<drive>/<folder>
}

View file

@ -180,6 +180,10 @@ module.exports = {
const DROP = new Set(["set-cookie", "connection", "keep-alive", "transfer-encoding"]);
api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => {
const u = new URL(request.url);
// pithos.sia/l/<id>/<name>: a public link, served by navigate.st/sia
if (/^\/l\/[A-Za-z0-9_-]{4,64}(\/|$)/.test(u.pathname)) {
return new Response(null, { status: 302, headers: { location: `https://navigate.st/sia/${u.pathname.slice(3)}${u.search}`, "cache-control": "no-store" } });
}
const fetchSite = request.headers.get("sec-fetch-site");
const origin = request.headers.get("origin");
const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none"

View file

@ -457,3 +457,21 @@ nav a.nav-account.active .na-label { color: var(--accent); }
/* The Pithos name is a link to Overview */
a.brand { color: inherit; text-decoration: none; }
a.brand:hover > span:first-of-type { color: var(--accent); }
/* Drive header: who can read it, and Share, beside the path */
.crumb-row { gap: 8px; align-items: center; }
.chip { border: 1px solid var(--border); background: var(--surface-2); color: inherit; font: inherit; font-size: 12.5px; padding: 3px 10px; border-radius: 999px; cursor: pointer; white-space: nowrap; }
.chip:hover { border-color: var(--accent); }
.access-chip.private { color: var(--muted); }
.access-chip.public { color: var(--accent); border-color: var(--accent); }
@media (max-width: 760px) { .crumb-row { row-gap: 6px; } .chip { font-size: 12px; padding: 2px 8px; } }
/* Public links in the Share dialogs */
.link-card { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; background: var(--surface-2); }
.link-card.fresh { border-color: var(--accent); }
.link-card .link-url { color: var(--accent); font-family: ui-monospace, Consolas, monospace; font-size: 13px; word-break: break-all; text-decoration: none; }
.link-card .link-url:hover { text-decoration: underline; }
.link-card code { font-size: 12px; word-break: break-all; }
.link-card .row { gap: 6px; flex-wrap: wrap; }
.link-make { gap: 10px; align-items: center; flex-wrap: wrap; }
.link-card a.btn { text-decoration: none; }

View file

@ -221,6 +221,8 @@ function currentRoute() {
const segs = location.pathname.split('/').filter(Boolean);
if (!segs.length) return [];
const first = decodeURIComponent(segs[0]);
// pithos.sia/app: the app's front door (the bare pithos.sia is the website)
if (first === 'app') return segs.slice(1);
if (first === 'drives') return ['buckets'];
if (views[first]) return segs;
if (state.selectedUser !== first) { state.selectedUser = first; try { localStorage.setItem('pithos.user', first); } catch {} }
@ -1614,11 +1616,28 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)]));
const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } });
// Private or public at a glance, one click to change; Share for this folder
// (or, at the top, the whole drive).
const accessChip = h('button', { type: 'button', class: 'chip access-chip', title: 'Who can read this drive. Click to change.', onclick: async () => { await accessDialog(user, bucket); paintAccess(); } }, '…');
async function paintAccess() {
try {
let mode;
if (isHosted()) {
const rules = await api('GET', '/api/hosted/rules');
const pub = rules.find((r) => r.bucket === bucket && r.prefix === '' && r.type === 'public');
mode = pub ? pub.rights : 'private';
} else mode = (await api('GET', `${base}/policy`)).public;
accessChip.className = `chip access-chip ${mode === 'private' ? 'private' : 'public'}`;
put(accessChip, { private: isHosted() ? '🔒 Private · encrypted' : '🔒 Private', read: '🌐 Public', 'read-list': '🌐 Public + list', custom: '⚙ Custom' }[mode] || mode);
} catch { put(accessChip, 'Access'); }
}
paintAccess();
const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : siaFolderLink(prefix));
const shareBtn = h('button', { type: 'button', class: 'chip', title: prefix ? 'Share this folder' : 'Share this drive', onclick: shareHere }, '🔗 Share');
put(head,
h('div', { class: 'stack' }, crumbs, accountLine()),
h('div', { class: 'stack' }, h('div', { class: 'row crumb-row' }, crumbs, accessChip, shareBtn), accountLine()),
h('div', { class: 'row' }, userPicker(userSel),
h('button', { class: 'btn', onclick: newFolder }, 'New folder'),
h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'),
h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput));
const uploads = h('div', { class: 'uploads' });
@ -1902,7 +1921,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
endpoint.addEventListener('change', gen);
gen();
await modal('Share', h('div', { class: 'stack' },
siaLinkBlock({ key }),
siaLinkBlock({ key, heading: true }),
h('details', { class: 'more-opts', open: !!savedEndpoint },
h('summary', {}, 'Link for this computer, or your own server'),
h('div', { class: 'stack', style: 'margin-top:8px' },
@ -1916,29 +1935,64 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
// A public link anyone can open, at navigate.st/sia: the file is read from
// your Sia account through Silent Mode, so it works with this computer off.
function siaLinkBlock({ key, prefix }) {
const days = h('select', {}, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l)));
const out = h('div');
// The links already made for this file, folder or drive come first, each
// with Copy, Open and Switch off; then one line to make a new one.
function siaLinkBlock({ key, prefix, heading = false }) {
const folder = prefix != null && key == null;
const mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key);
const days = h('select', { 'aria-label': 'Works for' }, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l)));
const list = h('div', { class: 'stack' });
const err = h('p', { class: 'error small', hidden: true });
const theseusForm = (u) => { try { return `pithos.sia/l/${new URL(u).pathname.replace(/^\/sia\//, '')}`; } catch { return ''; } };
const card = (l, fresh) => h('div', { class: `link-card${fresh ? ' fresh' : ''}` },
h('a', { class: 'link-url', href: l.url, target: '_blank', rel: 'noopener', title: l.url }, l.url.replace(/^https:\/\//, '')),
h('div', { class: 'small muted' }, `Works until ${new Date(l.until * 1000).toLocaleDateString()}${l.files > 1 ? ` · ${l.files} files` : ''}`,
theseusForm(l.url) && [' · in Theseus also ', h('code', {}, theseusForm(l.url))]),
h('div', { class: 'row' },
h('button', { type: 'button', class: 'btn small primary', onclick: () => copy(l.url, 'Link copied') }, 'Copy link'),
h('a', { class: 'btn small', href: l.url, target: '_blank', rel: 'noopener' }, 'Open'),
h('button', { type: 'button', class: 'btn small danger', onclick: async () => {
if (!(await confirmModal('Switch this link off?', 'Anyone who has it will no longer be able to open it.', 'Switch off'))) return;
try { await api('DELETE', `/api/sia-links/${encodeURIComponent(l.id)}`); toast('Link switched off'); refresh(); } catch (e) { fail(e); }
} }, 'Switch off')));
let freshId = null;
async function refresh() {
let links = [];
try { links = (await api('GET', '/api/sia-links')).filter(mine); } catch {}
put(list, links.map((l) => card(l, l.id === freshId)));
put(btn, links.length ? 'Make another link' : 'Create public link');
}
const btn = h('button', { type: 'button', class: 'btn primary', onclick: async () => {
btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Making the link…');
btn.disabled = true; err.hidden = true;
put(btn, h('span', { class: 'spinner' }), ' Making the link…');
try {
const r = await api('POST', `${base}/sia-link`, { key, prefix, days: Number(days.value) });
put(out, secretRow('Public link', r.url), h('p', { class: 'small muted', style: 'margin:0' }, `Works until ${new Date(r.until * 1000).toLocaleDateString()}. Switch it off any time in Settings › Public links.`));
put(btn, 'Make another link');
} catch (e) { put(out, h('p', { class: 'error small', style: 'margin:0' }, e.message)); put(btn, 'Try again'); }
freshId = r.id;
await refresh();
copy(r.url, 'Link created and copied');
} catch (e) {
// an engine from before this version reads a whole-drive link as a file
err.textContent = folder && /a file is required/.test(e.message)
? 'Pithos is still running its previous engine. Restart Theseus to finish the update, then try again.'
: e.message;
err.hidden = false;
await refresh();
}
btn.disabled = false;
} }, 'Create public link');
refresh();
return h('div', { class: 'stack' },
h('h3', { style: 'margin:0' }, 'Public link'),
h('p', { class: 'small muted', style: 'margin:0' }, prefix
? 'A page with the files in this folder that anyone can open, on any device, even with this computer off. Files in sub-folders are not included.'
: 'Anyone with the link can open this file on any device, even with this computer off. It is read from your Sia account through navigate.st; only this file is shared.'),
h('div', { class: 'row' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn),
out);
heading && h('h3', { style: 'margin:0' }, 'Public link'),
h('p', { class: 'small muted', style: 'margin:0' }, folder
? `Anyone with the link sees ${prefix ? 'the files in this folder' : 'the files at the top of this drive'} and can open them on any device, even with this computer off. Sub-folders are not included.`
: 'Anyone with the link can open this file on any device, even with this computer off. Only this file is shared.'),
list,
h('div', { class: 'row link-make' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn),
err);
}
async function siaFolderLink(prefix) {
await modal(`Public link for ${prefix.slice(0, -1).split('/').pop()}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]);
await modal(`Share ${prefix ? prefix.slice(0, -1).split('/').pop() : bucket}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]);
}
// On Silent Mode a link is a rule on the server, and the file is stored
@ -2712,6 +2766,16 @@ function setupFooter() {
}
check.hidden = false;
check.addEventListener('click', () => scan(true));
// An update applied in place swaps the files, but the engine keeps running
// the version it loaded until Theseus restarts.
const installed = state.status?.installed;
if (installed && installed !== current) {
busy = true;
showChip(`↻ Restart to finish v${installed}`, '', `Pithos v${installed} is installed, but its engine still runs v${current} until Theseus restarts. Theseus reopens your tabs.`, async () => {
try { await bridge.invoke('update', { step: 'apply' }); } catch (e) { flash('⚠ Restart failed', 'err', e.message); }
});
return;
}
setTimeout(() => scan(false), 1500);
setInterval(() => scan(false), 6 * 3600 * 1000);
}

49
dev/signin-sites.test.cjs Normal file
View 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
View 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
View 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");
});

View file

@ -5,6 +5,10 @@
// origin-mismatched call, so a third-party page can inspect the API's
// SHAPE but can't invoke it against local user data.
const { contextBridge, ipcRenderer } = require("electron");
// Web tabs run this preload in iframes too (nodeIntegrationInSubFrames), so
// the password hooks below see logins inside frames. Everything else here
// stays top-frame only, as it always was.
if (window.top === window) {
// A click in the page closes the left panel (web app / add-on) unless it is
// pinned. Main ignores this unless a panel is open and the tab is active.
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
@ -31,3 +35,78 @@ contextBridge.exposeInMainWorld("errorpage", {
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
});
}
// ---- Password manager hooks -------------------------------------------------
// Runs in this preload's isolated world in every frame of a web tab (a login
// form inside an iframe counts too); nothing is exposed to the page. Two
// reports go to main, which takes the site from the committed URL of the
// frame that sent them, 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();
})();

View file

@ -0,0 +1,181 @@
// Runs ONE community extension outside the browser process.
//
// An extension used to be require()d into Electron's main process, where it
// could load `electron`, hook the unlock prompt, read the vault files and
// the wallet's store, or shell out to the OS keystore — its declared
// capabilities only bound an honest extension. Extensions that do not ship
// with Theseus now run here instead: a separate process started in Node mode
// under Node's permission model, allowed to read nothing but its own folder
// (and write nothing but its own scratch folder), with no child processes,
// no workers, no native add-ons and no Electron. Everything it can do to the
// browser goes through the message channel below, and the host answers only
// the calls on its allow-list, with the same capability checks as before.
//
// The `api` object keeps the in-process shape where it can. Differences an
// extension author will meet:
// - host calls return promises (tabs.active() included);
// - api.require / api.import are gone — bundle your dependencies;
// - registerRequestFilter and registerSiteRoute are not offered (both hand
// the host a function it would have to call synchronously);
// - vault.imports, vault.pin and vault.lifecycle.unlock/setup/lock are
// built-in only, as they already were.
"use strict";
const { AsyncLocalStorage } = require("node:async_hooks");
const callScope = new AsyncLocalStorage(); // which page call a host request belongs to
let seq = 0;
const pending = new Map(); // id -> { resolve, reject }
const handlers = new Map(); // message name -> fn(payload, ctx)
const tabListeners = new Set();
let mod = null;
// Messages cross the process boundary as JSON (the binary 'advanced' channel
// format is tied to the exact V8 build on both ends). Bytes and BigInts are
// tagged so they arrive as what they were.
function wireEnc(v, depth = 0) {
if (depth > 40) throw new Error("value too deeply nested");
if (v === null || v === undefined) return v === undefined ? undefined : null;
if (typeof v === "bigint") return { __wire: "big", v: v.toString() };
if (typeof v === "function" || typeof v === "symbol") return undefined;
if (typeof v !== "object") return v;
if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") };
if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") };
if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; });
if (v instanceof Date) return v.toISOString();
const out = {};
for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; }
return out;
}
function wireDec(v) {
if (v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map(wireDec);
if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64"));
if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v);
const out = {};
for (const k of Object.keys(v)) out[k] = wireDec(v[k]);
return out;
}
const send = (m) => { try { process.send(wireEnc(m)); } catch { /* host is gone */ } };
const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined });
const plain = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
function hostCall(path, args) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
const scope = callScope.getStore();
send({ t: "api", id, path, args, callId: scope ? scope.callId : null });
});
}
const builtInOnly = (what) => () => Promise.reject(new Error(`${what} is reserved for built-in add-ons`));
const notOffered = (what, why) => () => { throw new Error(`${what} is not available to sandboxed extensions — ${why}`); };
function makeApi(init) {
const store = init.store && typeof init.store === "object" ? init.store : {};
const call = (path) => (...args) => hostCall(path, args);
return {
id: init.manifest.id,
folder: init.folder,
dataDir: init.scratchDir,
features: Object.freeze({ ...(init.features || {}), sandboxed: true }),
log: (...a) => send({ t: "log", args: a.map((x) => (typeof x === "string" ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })())) }),
// The store is this extension's own key/value file. It is handed over
// whole at start and written through, so get() stays synchronous.
storage: {
get: (key, fallback = null) => (Object.prototype.hasOwnProperty.call(store, key) && store[key] != null ? plain(store[key]) : fallback),
set: (key, value) => {
const k = String(key);
if (value === null || value === undefined) delete store[k]; else store[k] = plain(value);
send({ t: "storage.set", key: k, value: value === undefined ? null : plain(value) });
},
all: () => plain(store),
},
onMessage: (name, fn) => {
if (typeof name !== "string" || !name || typeof fn !== "function") throw new Error("onMessage needs (name, fn)");
handlers.set(name, fn);
send({ t: "handler", name });
},
emit: (msg, payload) => { hostCall("emit", [String(msg), payload]).catch(() => {}); },
registerSidebarPanel: (spec) => { hostCall("registerSidebarPanel", [spec]).catch((e) => send({ t: "log", args: ["registerSidebarPanel: " + e.message] })); },
revealSidebar: (panelId) => { hostCall("revealSidebar", [panelId]).catch(() => {}); },
openTab: call("openTab"),
openSettings: call("openSettings"),
setSessionProxy: call("setSessionProxy"),
captureTab: call("captureTab"),
saveCapture: call("saveCapture"),
scanActiveTabForUris: call("scanActiveTabForUris"),
approvalModal: call("approvalModal"),
checkAndStageSelfUpdate: call("checkAndStageSelfUpdate"),
applySelfUpdate: call("applySelfUpdate"),
restartApp: call("restartApp"),
startAtLaunch: (on) => { hostCall("startAtLaunch", [!!on]).catch(() => {}); },
whenUiReady: call("whenUiReady"),
tabs: {
active: call("tabs.active"),
onChange: (cb) => {
if (typeof cb !== "function") return () => {};
tabListeners.add(cb);
send({ t: "tabs.watch" });
return () => tabListeners.delete(cb);
},
},
vault: {
derive: call("vault.derive"),
requestUnlock: call("vault.requestUnlock"),
lifecycle: {
status: call("vault.lifecycle.status"),
unlock: builtInOnly("vault.lifecycle.unlock"),
setup: builtInOnly("vault.lifecycle.setup"),
lock: builtInOnly("vault.lifecycle.lock"),
},
imports: { list: builtInOnly("vault.imports"), add: builtInOnly("vault.imports"), remove: builtInOnly("vault.imports"), signer: builtInOnly("vault.imports") },
pin: { status: builtInOnly("vault.pin"), unlock: builtInOnly("vault.pin"), set: builtInOnly("vault.pin"), clear: builtInOnly("vault.pin") },
},
registerRequestFilter: notOffered("registerRequestFilter", "the host would have to call into the extension synchronously for every request"),
registerSiteRoute: notOffered("registerSiteRoute", "site routes are for built-in add-ons"),
require: notOffered("api.require", "bundle the modules you need inside the extension folder"),
import: notOffered("api.import", "bundle the modules you need inside the extension folder"),
};
}
process.on("message", async (raw) => {
if (!raw || typeof raw !== "object") return;
const m = wireDec(raw);
if (m.t === "res") {
const p = pending.get(m.id);
if (!p) return;
pending.delete(m.id);
if (m.ok) p.resolve(m.value);
else { const e = new Error(m.error && m.error.message || "host call failed"); if (m.error && m.error.code != null) e.code = m.error.code; p.reject(e); }
return;
}
if (m.t === "activate") {
try {
mod = require(m.mainPath);
if (!mod || typeof mod.activate !== "function") throw new Error("main file must export an activate(api) function");
await mod.activate(makeApi(m));
send({ t: "activated", ok: true });
} catch (e) { send({ t: "activated", ok: false, error: errOut(e) }); }
return;
}
if (m.t === "call") {
const fn = handlers.get(m.name);
if (!fn) { send({ t: "res", id: m.id, ok: false, error: { message: `no handler for "${m.name}"` } }); return; }
try {
const value = await callScope.run({ callId: m.id }, () => fn(m.payload, m.ctx || {}));
send({ t: "res", id: m.id, ok: true, value: value === undefined ? null : value });
} catch (e) { send({ t: "res", id: m.id, ok: false, error: errOut(e) }); }
return;
}
if (m.t === "tabs") { for (const cb of tabListeners) { try { cb(m.data); } catch {} } return; }
if (m.t === "deactivate") {
try { if (mod && typeof mod.deactivate === "function") await mod.deactivate(); } catch {}
process.exit(0);
}
});
// An extension's stray rejection or exception must not take its process down
// silently — say so in the host log and keep serving.
process.on("uncaughtException", (e) => send({ t: "log", args: ["uncaught exception: " + ((e && e.stack) || e)] }));
process.on("unhandledRejection", (e) => send({ t: "log", args: ["unhandled rejection: " + ((e && e.message) || e)] }));
process.on("disconnect", () => process.exit(0));
send({ t: "ready" });

83
lib/signin-sites.cjs Normal file
View 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
View 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 };

View file

@ -9,7 +9,7 @@
// useless on another machine or OS account.
//
// 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
// 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
@ -29,7 +29,8 @@
// attacker on the machine.
//
// 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> }
"use strict";
@ -41,7 +42,9 @@ const tpmPin = require("./tpm-pin.cjs");
const MAX_FAILS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
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() }) {
const sealAvailable = () => {
@ -101,13 +104,16 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
lockedMs: lockedMsOf(rec),
sealed: !!(rec && rec.sealed),
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(),
};
},
// Caller must have verified masterPassword against the vault first.
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 (!sealAvailable()) throw new Error("this system has no protected keystore, so a PIN cannot be stored safely");
const old = blobOrNull(read());
@ -122,7 +128,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
if (hw) key = tpm.mixKey(hw.secret, key);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]);
const blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS };
const 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 };
write({
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 });
const locked = lockedMsOf(rec);
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.
rec.fails = (rec.fails || 0) + 1;
rec.last = now();
@ -205,4 +219,4 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
return self;
}
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS };
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS, PIN_MIN, PIN_MAX };

590
main.js
View file

@ -4,7 +4,7 @@
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage } = require("electron");
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain } = require("electron");
const path = require("path");
const url = require("url");
const http = require("http");
@ -491,6 +491,11 @@ const SETTINGS_DEFAULTS = {
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession)
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)
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)
@ -897,9 +902,12 @@ const SETTINGS_ONLY = new Set([
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
"password-setup", "password-status", "password-unlock", "password-update",
"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",
"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
// vaultImports shim in main), but an unguarded handler is reachable by any
// 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
// Chromium's clearStorageData taxonomy — we group them into a small user-
// 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;
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 = [];
if (cookies) storages.push("cookies");
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
@ -1783,14 +1817,18 @@ function certFp(cert) {
const fp = cert && cert.fingerprint256;
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";
if (useTor) await loadSocks();
return new Promise((resolve, reject) => {
const opts = {
host: ip, port, servername, method: "GET", path: reqPath,
headers: { host: servername, "user-agent": "theseus/1" },
host: ip, port, servername, method: init.method || "GET", path: reqPath,
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.
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = https.request(opts, (res) => {
@ -1801,39 +1839,68 @@ async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
}
const chunks = [];
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.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";
if (useTor) await loadSocks();
return new Promise((resolve, reject) => {
const opts = {
host: ip, port: 80, method: "GET", path: reqPath,
headers: { host: hostHeader, "user-agent": "theseus/1" },
host: ip, port: 80, method: init.method || "GET", path: reqPath,
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}`);
const req = http.request(opts, (res) => {
const chunks = [];
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);
});
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
// 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
// the pin.
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) {
if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase());
return await httpGetByIp(ip, reqPath, hostHeader);
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint, init = {}) {
if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase(), init);
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
// 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.
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
function upstreamResponse(up, contentType) {
const headers = { "content-type": contentType };
if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location;
const headers = new Headers({ "content-type": contentType });
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 });
}
@ -2123,7 +2192,7 @@ async function serveBns(request) {
// record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the
// 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));
};
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
@ -2759,8 +2828,9 @@ function initAddons() {
let pw;
try { pw = await vaultPin().open(String(pin || "")); }
catch (err) {
return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0,
error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message : undefined };
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
: err.code === "wrong-length" ? `Your PIN has ${err.length} digits. Use the master password, or update Aegis.` : undefined };
}
try { await unlockVaultWithMaster(pw); }
catch {
@ -3556,25 +3626,39 @@ function setSidebar(show, panelId) {
emitSidebarState();
}
}
function showPwFill(show, matches) {
function showPwFill(show, matches, extra = {}) {
if (!pwFillPop) return;
if (show) {
if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches))) return;
if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches, extra))) return;
positionPwFill();
win.contentView.removeChildView(pwFillPop);
win.contentView.addChildView(pwFillPop);
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 || "",
framed: !!extra.framed, topHost: extra.topHost || "" });
} else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
}
// 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).
// 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) {
if (!vaultState || !host) return [];
const h = String(host).toLowerCase();
return (vaultState.entries || [])
.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
// navigations only, so it goes stale on Back/Forward and server redirects —
@ -3589,6 +3673,8 @@ function liveHost(t) {
// 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.
function emitPwAvailability() {
refreshSigninSites();
if (!vaultState && theseusIdInst) theseusIdInst.forget(); // drop the decrypted Theseus ID record on lock
const t = activeTab();
const host = t ? liveHost(t) : "";
const count = pwMatchesForHost(host).length;
@ -3598,11 +3684,16 @@ function emitPwAvailability() {
// password field + tries to fill the adjacent/associated username field.
// Kept intentionally small — the whole autofill affordance is opt-in
// (user clicks the chip; nothing runs on page load).
async function pwFillIntoActiveTab(entry) {
// frameRef: the iframe the login was offered in (null = the tab's own page).
async function pwFillIntoActiveTab(entry, frameRef = null) {
const t = activeTab(); if (!t) return false;
// Re-check at fill time: the page may have navigated since the picker opened.
if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" };
const wc = t.view.webContents;
// Re-check at fill time: the page (or frame) may have navigated since the
// picker opened. A frame must still be that tab's, still on that site.
let target = t.view.webContents;
if (frameRef) {
target = pwFrameFromRef(frameRef, t, entry.domain);
if (!target) return { ok: false, why: "origin-changed" };
} else if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" };
const script = `(() => {
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible);
@ -3627,8 +3718,7 @@ async function pwFillIntoActiveTab(entry) {
return { ok: true, filledUsername: !!user };
})()`;
try {
const res = await wc.executeJavaScript(script, true);
return res;
return await target.executeJavaScript(script, true);
} catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; }
}
function showAddressPicker(show, suggestions) {
@ -3837,6 +3927,7 @@ function setActive(id) {
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(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
// A user action that switches to a different tab (New Tab, Settings,
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
@ -4115,7 +4206,12 @@ function createTab(initial, opts = {}) {
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
: path.join(__dirname, "home-preload.js");
const view = new WebContentsView({ webPreferences: { preload: preloadPath } });
// Web tabs also run preloads inside iframes, so the password manager sees
// logins in embedded sign-in forms. Pages still get no Node access
// (nodeIntegration stays off); every other preload returns early outside
// the top frame, as before.
const webTab = !opts.settings && !opts.addonFile;
const view = new WebContentsView({ webPreferences: { preload: preloadPath, ...(webTab ? { nodeIntegrationInSubFrames: true } : {}) } });
// Explicit solid background: transparent (Electron default) makes the tab
// view flash to whatever's underneath (which can be the just-hidden tab or
// black) between setVisible(true) and the first paint on tab switch. A
@ -6190,7 +6286,7 @@ function routeWizUri(uri, pageUrl, tabId) {
return false;
}
const send = () => addonHost
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId })
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId, registry: /^bns:/i.test(String(pageUrl || "")) ? "bns" : "web" })
.catch((err) => console.log("[wiz] pairing failed:", err?.message || err));
// A wallet that starts on first use is woken by the link itself.
if (addonHost.isDormant("aegis")) {
@ -6220,7 +6316,10 @@ ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`);
const origin = pageOriginOf(url);
if (!origin) throw new Error("opaque origin");
return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id });
// `registry` says which name system served the page: the same name can be
// one site on BNS and another on the ordinary web, and `origin` alone
// (bns:// shown as https://) cannot tell an add-on which it is talking to.
return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id, registry: /^bns:/i.test(url) ? "bns" : "web" });
});
// Synchronous — the inject preload has to know what to run before the page's
// own scripts start. Decided against the sender's committed URL; the href the
@ -6481,7 +6580,9 @@ function showApprovalModal(opts, addonId, tabId = null) {
const req = {
reqId: ++approvalSeq,
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?"),
body: opts.body == null ? "" : String(opts.body),
origin: opts.origin == null ? "" : String(opts.origin),
@ -7347,21 +7448,23 @@ let unlockCurrent = null;
let unlockSeq = 0;
// Resolves { ok: true } once the vault is unlocked, { ok: false, reason }
// when there is no vault or the user cancels.
function requestVaultUnlock({ reason, addonId } = {}) {
if (vaultState) return Promise.resolve({ ok: true, already: true });
// confirm: ask for the PIN or master password even when the vault is already
// 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" });
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(); });
}
function pumpUnlock() {
if (unlockCurrent || !unlockQueue.length || !unlockPop) return;
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;
const st = vaultPin().status();
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
unlockPop.setVisible(true);
unlockPop.webContents.focus();
@ -7389,12 +7492,21 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
catch (err) {
// 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-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.` };
return { ok: false, mode: "password", error: err.message };
}
}
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 {
if (mode === "pin") {
// The PIN opened, but its master password no longer opens the vault
@ -7424,7 +7536,18 @@ ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => {
catch (e) { return vaultErr(e.message); }
});
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-confirm", (_e, reason) => requestVaultUnlock({ confirm: true, reason: String(reason || "").slice(0, 200) }));
ipcMain.handle("password-status", () => ({
setup: fs.existsSync(vaultFile()),
@ -7497,6 +7620,11 @@ ipcMain.handle("password-get", async (_e, id) => {
if (!vaultState) return vaultErr("locked");
try {
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);
return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); }
@ -7509,6 +7637,7 @@ ipcMain.handle("password-add", async (_e, spec) => {
const entry = v.newEntry(spec || {});
vaultState.entries.push(entry);
await v.saveVault(vaultFile(), vaultState);
emitPwAvailability();
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
@ -7521,10 +7650,13 @@ ipcMain.handle("password-update", async (_e, id, patch) => {
if (!e) return vaultErr("no such entry");
// 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];
// 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.
if (patch && "literal" in patch) delete e.generated;
if (patch && "generated" in patch) delete e.literal;
await v.saveVault(vaultFile(), vaultState);
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
@ -7535,6 +7667,7 @@ ipcMain.handle("password-remove", async (_e, id) => {
const v = await loadVaultLib();
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
await v.saveVault(vaultFile(), vaultState);
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
@ -7785,6 +7918,7 @@ ipcMain.handle("toggle-pw-fill", async (_e, rect) => {
const matches = pwMatchesForHost(host);
if (!matches.length) return showPwFill(false);
if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
pwfOffer = null; // the chip fills the tab's own page
showPwFill(true, matches);
});
ipcMain.handle("close-pw-fill", () => showPwFill(false));
@ -7800,15 +7934,378 @@ ipcMain.handle("pw-fill-resize", (_e, h) => {
ipcMain.handle("pw-fill-pick", async (e, id) => {
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
showPwFill(false);
const t = activeTab();
// The offer came from a login field (possibly in an iframe), or from the
// toolbar chip (the tab's own page).
const offer = pwfOffer && t && pwfOffer.tabId === t.id ? pwfOffer : null;
const host = offer ? offer.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, framed: !!(offer && offer.ref), topHost: offer && offer.topHost }); return { ok: true, shown: matches.length }; }
}
if (!vaultState) return { ok: false, err: "locked" };
try {
const v = await loadVaultLib();
const entry = vaultState.entries.find((x) => x.id === id);
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);
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password });
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }, offer && offer.ref);
} 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 every frame of every web tab (iframes included:
// sign-in widgets and embedded checkouts often live in one), 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 site is always taken from the committed URL of the frame
// that sent the report, never from the message: a login inside an iframe
// from login.example belongs to login.example, whatever page embeds it.
function frameHost(frame) {
try {
const u = new URL(frame.url);
return /^(https?|bns):$/.test(u.protocol) ? u.hostname.toLowerCase() : "";
} catch { return ""; }
}
function pwSource(e) {
const t = tabForSender(e.sender);
if (!t || t.settings || t.addonId) return null;
const frame = e.senderFrame;
if (!frame || frame.detached) return null;
const host = frameHost(frame);
if (!host) return null;
const isMain = frame === e.sender.mainFrame;
return { t, frame, host, isMain, topHost: liveHost(t), ref: isMain ? null : { processId: frame.processId, routingId: frame.routingId } };
}
// An iframe the user was offered a login in, if it still exists, still
// belongs to that tab, and still shows that site.
function pwFrameFromRef(ref, t, host) {
let f = null;
try { f = webFrameMain.fromId(ref.processId, ref.routingId); } catch { return null; }
if (!f || f.detached || !t || f.top !== t.view.webContents.mainFrame) return null;
return host && frameHost(f) !== host ? null : f;
}
let pwfOffer = null; // { tabId, host, ref (null = the tab's own page), topHost }
const pwfNavHooked = new WeakSet();
ipcMain.on("pw-form", (e, rect) => {
try {
if (!settings.pwOfferFill) return;
const src = pwSource(e);
if (!src || src.t.id !== activeId) return;
const { t, host } = src;
if (!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();
if (src.isMain) {
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)) };
} else {
// A field inside an iframe: its position is only known inside that
// frame, so the offer sits at the top of the page, naming the site.
pwfPos = { x: Math.round(b.x + (b.width - PWF_W) / 2), y: b.y + 10 };
}
pwfOffer = { tabId: t.id, host, ref: src.ref, topHost: src.topHost };
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 && pwfOffer && pwfOffer.tabId === t.id) showPwFill(false);
});
}
showPwFill(true, matches, { locked, host, framed: !src.isMain, topHost: src.topHost });
} catch (err) { console.warn("pw-form:", err?.message); }
});
ipcMain.on("pw-form-dismiss", (e) => {
const src = pwSource(e);
if (src && pwfVisible && pwfOffer && pwfOffer.tabId === src.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 src = pwSource(e);
if (!src) return;
const { t, host } = src;
if ((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, { ref: src.ref, topHost: src.topHost })
.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 (or the iframe the form was in)
// moved to another site or went away, 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, ref) {
const CHECK = "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)";
for (let i = 0; i < 8; i++) {
const t = tabs.find((x) => x.id === tabId);
if (!t) return false;
let pwField = false;
if (ref) {
const f = pwFrameFromRef(ref, t, host);
if (!f) return true; // the frame navigated away or was removed
try { pwField = await f.executeJavaScript(CHECK); } catch { return true; }
} else {
if (liveHost(t) !== host) return true;
try {
pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{ code: CHECK }]);
} 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, { ref = null, topHost = "" } = {}) {
if (!tabs.some((x) => x.id === tabId)) return;
if (!(await pwLoginSettled(tabId, host, ref))) return;
// A login in an iframe is saved for the iframe's site; say so.
const where = ref && topHost && topHost !== host ? `${host} (in a frame on ${topHost})` : host;
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: where,
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: where,
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
// selection cursor without stealing focus from the address input.
ipcMain.handle("address-cursor", (_e, dir) => {
@ -8808,13 +9305,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
// 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.
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;
if (typeof ses.registerPreloadScript === "function") {
// 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 {
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]);
}
} catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); }
@ -8897,6 +9396,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
cookies: settings.clearCookiesOnQuit,
cache: settings.clearCacheOnQuit,
storage: settings.clearStorageOnQuit,
keep: settings.keepSignInsOnQuit ? signinSites().keepOrigins() : [],
});
// Session file AND the address-bar history (history.json).
if (settings.clearHistoryOnQuit) {

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.79",
"version": "0.3.82",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
@ -80,6 +80,7 @@
"approval.html",
"unlock.html",
"unlock-preload.js",
"theseus-id-preload.js",
"auth-prompt-preload.js",
"auth-prompt.html",
"addon-inject-preload.js",
@ -127,6 +128,14 @@
"from": "../Argus/src/lib/password-vault.js",
"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",
"to": "bns-index-core.mjs"

View file

@ -25,14 +25,25 @@
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;");
const esc = (s) => String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
window.pwfill.onMatches((data) => {
const list = $("list");
const matches = data.matches || [];
if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; }
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)}</div></span></div>`
// A login form inside an iframe belongs to the iframe's site: name it,
// and the page it sits in, so the user knows where the password goes.
const framed = data.framed && data.topHost && data.topHost !== data.host;
const hdr = document.querySelector(".hdr");
hdr.textContent = framed
? `Login for ${data.host} · in a frame on ${data.topHost}`
: data.locked ? "Theseus Vault" : "Fill password for this site";
hdr.style.textTransform = framed ? "none" : ""; // host names stay as written
if (data.locked) {
// 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("");
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id));
report();

View file

@ -29,6 +29,20 @@ contextBridge.exposeInMainWorld("cfg", {
pinStatus: () => ipcRenderer.invoke("vault-pin-status"),
pinSet: (pin, masterPassword) => ipcRenderer.invoke("vault-pin-set", { pin, masterPassword }),
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"),
// 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.

View file

@ -117,12 +117,12 @@
.ariadne-sources input[type=checkbox]{accent-color:var(--acid);width:18px;height:18px;cursor:pointer}
.pmuted{color:var(--mut)}
/* Settings › Apps */
.app{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin-bottom:8px}
.app img,.app .ph{width:32px;height:32px;border-radius:7px;flex:none;display:grid;place-items:center;font-size:20px}
.app .nm{flex:1;min-width:0} .app .nm b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.app .nm span{color:var(--mut);font-size:12.5px}
.app .btn.rm{background:transparent;color:#f6768a;border-color:rgba(246,118,138,.4)}
.app .btn.rm:hover{background:rgba(246,118,138,.12)}
.webapp{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin-bottom:8px}
.webapp img,.webapp .ph{width:32px;height:32px;border-radius:7px;flex:none;display:grid;place-items:center;font-size:20px}
.webapp .nm{flex:1;min-width:0} .webapp .nm b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.webapp .nm span{color:var(--mut);font-size:12.5px}
.webapp .btn.rm{background:transparent;color:#f6768a;border-color:rgba(246,118,138,.4)}
.webapp .btn.rm:hover{background:rgba(246,118,138,.12)}
.ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px}
.ceng .cs{font-size:14px;flex:none}
/* engine checklist */
@ -259,6 +259,40 @@
.ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; }
.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-act .btn:disabled{opacity:.45;cursor:default}
.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:14px;height:14px;border-radius:50%;border:1.5px solid var(--dim);transition:background .12s,border-color .12s}
.pinpad .pindot.optional{border-style:dashed;opacity:.6} /* digits 7 and 8: allowed, not needed */
.pinpad .pindot.on{background:var(--acid);border-color:var(--acid)}
.pinpad .pinkeys{display:grid;grid-template-columns:repeat(3,76px);gap:10px}
.pinpad .pinkeys button{height:56px;border-radius:12px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:500 24px system-ui,sans-serif;cursor:pointer}
/* Keys only lighten on hover; an acid border lighting up on each key the pointer crossed read as flashing. */
.pinpad .pinkeys button{transition:background-color .08s,transform .06s}
.pinpad .pinkeys button:hover{background:#232c3b}
.pinpad .pinkeys button:active{transform:scale(.96)}
.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:hover{background:#e4e9f0}
.pinpad .pinkeys button.util{background:transparent}
}
</style></head>
<body>
<div class="app">
@ -269,6 +303,7 @@
<a data-sec="language">Language</a>
<a data-sec="search">Search</a>
<a data-sec="passwords">Passwords</a>
<a data-sec="theseusid">Theseus ID</a>
<a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a>
<a data-sec="plugins">Plug-ins</a>
@ -594,13 +629,8 @@
<!-- State B: vault exists but locked -->
<div id="pwLocked" hidden>
<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="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
</div>
<div 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 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"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
</div>
</div>
<!-- State C: vault unlocked -->
@ -612,14 +642,19 @@
<h2 class="sub">Quick-unlock PIN</h2>
<div class="row">
<div class="txt"><div class="t">PIN</div>
<div class="d" id="pinDesc">A 6-digit PIN that unlocks the vault instead of the master password, here, 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>
<div id="pinForm" class="row" style="flex-direction:column;align-items:stretch;gap:8px" hidden>
<div class="addeng"><input id="pinMaster" type="password" placeholder="Master password" autocomplete="current-password"></div>
<div class="addeng"><input id="pinNew1" type="password" inputmode="numeric" maxlength="6" placeholder="New 6-digit PIN" autocomplete="off"><input id="pinNew2" type="password" inputmode="numeric" maxlength="6" placeholder="Repeat PIN" autocomplete="off"></div>
<div id="pinErr" class="pmuted" style="color:#f6768a;font-size:12.5px" hidden></div>
<div style="display:flex;justify-content:flex-end;gap:6px"><button id="pinCancel" class="btn" type="button">Cancel</button><button id="pinSave" class="btn" type="button">Save PIN</button></div>
<!-- Step-by-step PIN setup: master password → choose PIN → repeat → done. -->
<div id="pinWiz" class="pinwiz" hidden>
<div class="pinwiz-box" role="dialog" aria-modal="true" aria-labelledby="pinWizTitle">
<div class="pinwiz-steps"><span data-step="1"></span><span data-step="2"></span><span data-step="3"></span></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 id="pwList"></div>
<h2 class="sub">Add an entry</h2>
@ -633,7 +668,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 style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></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>
</section>
<!-- NAMING -->
@ -815,6 +904,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>
<label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label>
</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="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>
@ -1169,7 +1262,8 @@
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
})();
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",
"sidebarHideOnNewTab", "sidebarHideOnRightPanel", "sidebarHideOnPageClick"];
// Text inputs that round-trip through settings-set on change. Trimmed; a
@ -2218,12 +2312,23 @@
pwLockedEl.hidden = which !== "locked";
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() {
pwRenderNever().catch(() => {});
const st = await C.pwStatus();
if (!st.setup) return pwShow("setup");
const pin = await C.pinStatus().catch(() => null);
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");
}
pwShow("unlocked");
@ -2237,7 +2342,7 @@
document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
document.getElementById("pinClearBtn").hidden = !set;
const desc = document.getElementById("pinDesc");
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here, 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.`
: 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."
@ -2245,33 +2350,133 @@
: base;
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
}
const pinForm = document.getElementById("pinForm");
const pinErr = document.getElementById("pinErr");
document.getElementById("pinSetBtn").onclick = () => { pinForm.hidden = false; pinErr.hidden = true; document.getElementById("pinMaster").focus(); };
document.getElementById("pinCancel").onclick = () => {
pinForm.hidden = true;
for (const id of ["pinMaster", "pinNew1", "pinNew2"]) document.getElementById(id).value = "";
};
document.getElementById("pinSave").onclick = async () => {
pinErr.hidden = true;
const master = document.getElementById("pinMaster").value;
const p1 = document.getElementById("pinNew1").value;
const p2 = document.getElementById("pinNew2").value;
const fail = (m) => { pinErr.textContent = m; pinErr.hidden = false; };
if (!/^\d{6}$/.test(p1)) return fail("The PIN must be 6 digits.");
if (p1 !== p2) return fail("The two PINs don't match.");
if (!master) return fail("Enter your master password to bind the PIN to it.");
const res = await C.pinSet(p1, master);
if (!res.ok) return fail(res.err === "wrong master password" ? "Wrong master password." : res.err);
document.getElementById("pinCancel").click();
pwRefresh();
// Step-by-step PIN setup. Step 1 proves the master password (the PIN
// wraps it), step 2 picks the PIN on a pad, step 3 repeats it, then
// main seals it. The master password stays in this closure only until
// the dialog closes.
const pinWiz = {
el: document.getElementById("pinWiz"),
step: 0, master: "", first: "", entry: "", busy: false,
open() {
Object.assign(this, { step: 1, master: "", first: "", entry: "", busy: false });
this.el.hidden = false;
this.render();
},
close() {
Object.assign(this, { step: 0, master: "", first: "", entry: "" });
this.el.hidden = true;
},
error(msg) { document.getElementById("pinWizErr").textContent = msg || ""; },
// Step 2 takes 6 to 8 digits (eight dots, the first six marked as
// needed); step 3 must repeat exactly as many. Next, or Enter, moves on;
// nothing advances by itself, so a slip can still be fixed with ⌫.
get len() { return this.step === 3 ? this.first.length : 8; },
ready() { return this.step === 3 ? this.entry.length === this.first.length : this.entry.length >= 6; },
press(k) {
if (this.busy || (this.step !== 2 && this.step !== 3)) return;
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();
},
paintDots() {
document.querySelectorAll("#pinWizBody .pindot").forEach((d, i) => d.classList.toggle("on", i < this.entry.length));
const nextBtn = document.getElementById("pinWizNext");
if (this.step === 2 || this.step === 3) nextBtn.disabled = !this.ready();
},
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 }, (_, i) => `<span class="pindot${this.step === 2 && i >= 6 ? " optional" : ""}"></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 = "Type 6 to 8 digits, then press Next. Longer is stronger.";
body.innerHTML = this.pad();
nextBtn.hidden = false; nextBtn.textContent = "Next";
} else if (this.step === 3) {
title.textContent = "Repeat your PIN";
text.textContent = `Type the same ${this.len} digits again, then press Save.`;
body.innerHTML = this.pad();
nextBtn.hidden = false; nextBtn.textContent = "Save PIN";
} 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));
nextBtn.disabled = false;
if (this.step === 2 || this.step === 3) this.paintDots(); // Next waits for enough digits
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.ready()) return this.error("Type at least 6 digits.");
this.first = this.entry; this.entry = ""; this.step = 3; this.render();
} else if (this.step === 3) {
if (!this.ready()) 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");
else if (e.key === "Enter" && (pinWiz.step === 2 || pinWiz.step === 3)) { e.preventDefault(); pinWiz.next(); }
});
document.getElementById("pinClearBtn").onclick = async () => {
if (!confirm("Remove the PIN? The vault will need the master password again.")) return;
await C.pinClear();
pwRefresh();
};
document.getElementById("pwPinUnlockBtn").onclick = async () => {
// 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();
if (r && r.ok) pwRefresh();
};
@ -2283,9 +2488,18 @@
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
`<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>` +
`<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 pwDel" title="Remove">✕</button>` +
`</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) => {
const id = ev.target.closest(".eng").dataset.id;
const res = await C.pwGet(id);
@ -2322,17 +2536,6 @@
document.getElementById("pwSetupMnemonic").value = "";
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
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
// Add-entry form: toggle literal input; wire preview + save
@ -2375,6 +2578,103 @@
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 ----
const addonsList = document.getElementById("addonsList");
// Per-addon update state, keyed by addon id. Populated by loadAddonUpdates()
@ -2624,7 +2924,7 @@
emptyEl.hidden = apps.length > 0;
listEl.innerHTML = apps.map((a) => {
let when = ""; try { if (a.installedAt) when = new Date(a.installedAt).toLocaleDateString(); } catch {}
return `<div class="app" data-key="${escA(a.key)}">` +
return `<div class="webapp" data-key="${escA(a.key)}">` +
(a.icon ? `<img src="${escA(a.icon)}" alt="">` : `<span class="ph">🧩</span>`) +
`<div class="nm"><b>${escA(a.name)}</b><span>${escA(a.host)}${when ? " · installed " + escA(when) : ""}${a.open ? " · open now" : ""}</span></div>` +
`<button class="btn" data-act="open">Open</button>` +
@ -2634,7 +2934,7 @@
}
listEl.addEventListener("click", async (e) => {
const b = e.target.closest("button[data-act]"); if (!b) return;
const key = b.closest(".app").dataset.key;
const key = b.closest(".webapp").dataset.key;
if (b.dataset.act === "open") { await A.webappOpen(key); renderApps(); }
else if (b.dataset.act === "sidebar") {
const app = ((await A.webapps()) || []).find((x) => x.key === key);

68
theseus-id-preload.js Normal file
View file

@ -0,0 +1,68 @@
// 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");
// Top frame only (main refuses iframes anyway): web tabs run preloads in
// iframes too, for the password hooks.
if (window.top === window && /^(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 {}
}

View file

@ -15,11 +15,11 @@
html, body { margin: 0; height: 100%; background: transparent; }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: grid; place-items: start center; padding-top: 48px; }
display: grid; place-items: center; padding: 16px; }
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
@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 .addon { color: var(--mut); }
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
@ -27,21 +27,30 @@
/* The same PIN pad as Aegis (panel.html .pinpad): one PIN, one screen. */
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 12px; }
.pinpad .pindots { display: flex; gap: 12px; }
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim);
.pinpad .pindot { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid var(--dim);
background: transparent; transition: background .12s, border-color .12s; }
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid);
box-shadow: 0 0 6px rgb(from var(--acid) r g b / .5); }
.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: 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); }
/* No glow, and keys only lighten on hover: a bright acid border lighting
up on whichever key the pointer crosses read as the pad flashing. */
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid); }
.pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 76px); gap: 10px; }
.pinpad .pinkeys button { height: 56px; border-radius: 12px; border: 1px solid var(--line);
background: var(--surface2); color: var(--ink); font: 500 24px system-ui, sans-serif; cursor: pointer; }
.pinpad .pinkeys button { transition: background-color .08s, transform .06s; }
.pinpad .pinkeys button:hover { background: rgb(from var(--surface2) calc(r + 10) calc(g + 10) calc(b + 12)); }
.pinpad .pinkeys button:active { transform: scale(.96); }
.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);
background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; }
input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
.err { color: var(--danger); font-size: 12.5px; min-height: 18px; margin-bottom: 6px; text-align: center; }
.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; }
.pbtns { display: flex; gap: 6px; }
.pbtn.primary:disabled { background: var(--surface2); color: var(--dim); border-color: var(--line); opacity: 1; }
.link { background: none; border: none; color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px; padding: 0; text-decoration: underline; }
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
@ -67,6 +76,7 @@
let req = null;
let mode = "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 error = "";
@ -74,6 +84,7 @@
if (!req) return;
const id = req.reqId;
req = null;
ui = null;
document.body.replaceChildren();
if (cancelled) window.unlock.cancel(id);
}
@ -81,67 +92,99 @@
async function submit(value) {
if (!req || busy) return;
busy = true;
render();
paint();
const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message }));
busy = false;
if (r && r.ok) { req = null; document.body.replaceChildren(); return; }
if (r && r.ok) { req = null; ui = null; document.body.replaceChildren(); return; }
error = (r && r.error) || "Could not unlock";
if (r && r.mode) mode = r.mode;
if (r && r.pinLength) pinLength = r.pinLength;
pin = "";
render();
paint(); // re-renders only if the answer switched mode or PIN length
}
function press(d) {
if (busy) return;
if (d === "back") pin = pin.slice(0, -1);
else if (d === "clear") pin = "";
else if (pin.length < 6) pin += d;
else if (pin.length < pinLength) pin += d;
error = "";
render();
if (pin.length === 6) submit(pin);
paint();
}
// 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 || (mode === "pin" && pin.length !== pinLength);
}
}
function render() {
if (!req) return;
const replaying = !!ui; // a mode switch inside the same prompt: no second pop-in
ui = { mode, pinLength };
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"),
req.reason ? el("p", { class: "reason" }, req.reason) : null,
];
let bodyEls;
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 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 },
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);
// The PIN is sent with Unlock (or Enter) once every digit is in, not on
// the last keypress, so a slip can still be corrected with ⌫.
ui.go = el("button", { class: "pbtn primary", type: "button", onclick: () => { if (pin.length === pinLength) submit(pin); } }, "Unlock");
ui.go.disabled = pin.length !== pinLength;
bodyEls = [el("div", { class: "pinpad" }, dots, pad, ui.err),
el("div", { class: "pact" },
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"),
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; pin = ""; render(); } }, "Use master password"),
el("div", { class: "pbtns" },
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"),
ui.go))];
} else {
const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock");
if (busy) go.setAttribute("disabled", "");
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,
el("div", { class: "err" }, error),
ui.err,
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("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
bodyEls = [form];
setTimeout(() => input.focus(), 0);
}
document.body.replaceChildren(el("div", { class: "promptmask", onmousedown: (e) => { if (e.target.classList.contains("promptmask")) done(true); } },
el("div", { class: "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
el("div", { class: replaying ? "promptbox still" : "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
}
window.unlock.onShow((r) => {
req = r;
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
pinLength = r.pinLength || 6;
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.` : "";
busy = false;
ui = null; // a new prompt: build it fresh, with its pop-in
render();
});
document.addEventListener("keydown", (e) => {
@ -149,6 +192,7 @@
if (e.key === "Escape") return done(true);
if (mode === "pin" && /^[0-9]$/.test(e.key)) press(e.key);
else if (mode === "pin" && e.key === "Backspace") press("back");
else if (mode === "pin" && e.key === "Enter") { e.preventDefault(); if (pin.length === pinLength && !busy) submit(pin); }
});
</script>
</body>