Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus

Several concurrent workstreams committed together as a checkpoint:

- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
  AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
  main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
  (+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
  restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
  site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
  Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
This commit is contained in:
Local Dev 2026-08-14 23:17:18 +02:00
parent 56dba6d953
commit 0888048ace
6 changed files with 858 additions and 47 deletions

209
DESIGN-password-manager.md Normal file
View file

@ -0,0 +1,209 @@
# Theseus password manager — design
Built-in password manager local to Theseus, derived from a Bitcoin Cash-style
seed. The seed is the single root of trust; passwords are one purpose among
many (BCH wallet, messenger, future identity uses) all held under distinct
hardened derivation subtrees so a leak in one purpose can't compromise
another.
**Status**: design + Phase 1 (local vault, settings UI, no autofill, no sync).
## Threat model
**In scope:**
- Local process compromise reads plaintext passwords ONLY while the vault is
unlocked. Locked-vault-on-disk is opaque.
- Disk exfiltration (stolen laptop, forensic image) yields only the
encrypted vault. No plaintext, no seed material, no metadata about which
sites the user has passwords for.
- A malicious web page CANNOT ask the password manager for anything.
Autofill (phase 2) will happen via a Theseus-controlled contentScript
bound to the origin; there is no `window.passwords` API.
**Out of scope (phase 1):**
- Malware running with keyboard-input capability. (No password manager
survives a keylogger.)
- Physical shoulder-surfing when the vault is unlocked and shown.
- Backup / cloud sync — phase 2 (opt-in, Sia).
**Explicitly rejected:**
- Reusing Chromium's `chrome.storage` or Electron's `safeStorage` as the
sole cryptographic layer. Both are DPAPI-backed on Windows (encrypted
by the OS user's DPAPI key). Fine as a *belt* alongside our AES-GCM
*suspenders*, not as a standalone.
## Crypto
### Root
The user provides ONE of:
- Their BCH wallet seed (BIP39 mnemonic) — unified identity, one backup
- A fresh seed generated in Phase 1 setup — isolated from any BCH funds
Seed is never persisted by the password vault. What is persisted is a
per-purpose derived root, encrypted under the master password.
### Hardened derivation discipline
Every purpose gets its own subtree, using a purpose byte that is
distinct from BIP44's coin-type space:
```
BIP32-style: m / purpose' / subpurpose'
Passwords: m / 1381' / 0' (1381 = 0x555 = arbitrary picked; documented)
Messenger: m / 1414' / 0' (reserved for future)
BCH wallet: m / 44' / 145' (SLIP-44 coin 145 — untouched)
```
Hardened (`'`) means the parent public key alone cannot derive child keys
— you need the parent private key. So even if a password-purpose child
key leaks, an attacker cannot walk backward to the BCH wallet subtree.
### Vault key
```
master_key_material = PBKDF2(
masterPassword,
salt = 16 random bytes stored in vault header,
iterations = 200_000,
hash = SHA-256,
keylen = 32
)
vault_key = AES-256-GCM key(master_key_material)
```
200k PBKDF2 iterations balances phone-CPU login latency (~200ms) against
brute-force cost. Bumped to 600k on desktop-detected CPUs in Phase 2.
### Vault encryption
```
vault_on_disk = {
version: 1,
kdf: { name: "PBKDF2", iters: 200_000, salt: <hex> },
iv: <12-byte hex>,
ciphertext: <hex>, // AES-GCM(vault_key, iv, JSON.stringify(plaintext))
tag: <16-byte hex, appended>,
}
plaintext = {
purposeRoot: <32-byte hex>, // the m/1381'/0' node — derived once at setup
entries: [
{ id, domain, username, addedAt,
// one of:
literal: <ciphertext>, // legacy pasted password (encrypted with vault_key)
generated: { version, rules } // deterministic — re-derived from purposeRoot on demand
},
...
]
}
```
Every entry carries a stable `id` (UUIDv4) so autofill (phase 2) can bind
by id, not by domain+username (which can change).
### Deterministic derivation recipe (the "Generate" button)
For a `generated` entry, the password is not stored — it's computed:
```
info = "silentmode-passwords-v1|" + domain + "|" + username + "|v" + version
bits = HKDF(hash=SHA-256, key=purposeRoot, salt=<zero>, info) → 32 bytes
password = mapBytesToRules(bits, rules)
```
`rules` default:
```
{ length: 20, upper: true, lower: true, digits: true, symbols: true }
```
`mapBytesToRules` is a template scheme: take the first 4 bytes to seed a
DRBG, produce N chars from the requested character classes with guaranteed
inclusion of at least one from each enabled class. Same input → same
password on every device holding the seed.
Version-bumping (`v2` etc.) is how a user "rotates" a deterministic
password without ever losing the old one — old services rejecting a
rotation can still be logged into by looking up v1.
## Vault file
Location: `<userData>/passwords.vault` (single file).
Never written unencrypted. On save: build the new plaintext, encrypt with
a fresh IV, write atomically (`.tmp` + rename).
The file's presence is not itself sensitive — it just says "this user has
opted into the password manager". Contents are opaque.
## Runtime
- **Unlock state** lives in the main process only. Never sent to renderers
in plaintext except in response to explicit `password-get(id)` calls.
- Vault stays unlocked for the current session. Auto-locks on:
- Explicit lock button
- App quit (before the storage-clear ran)
- N minutes of settings-page inactivity — phase 2 knob
- No BROWSER autofill in phase 1. Users copy from Settings → Passwords.
## UI (phase 1)
New sidebar entry in Settings between Search and Naming: **Passwords**.
Two states:
**Locked / not-yet-set-up:**
- "Set up password vault" — one-time form:
- Master password (with confirm)
- Source of derivation seed: "Use my Ariadne wallet seed" (default) OR "Generate a new seed for passwords"
- "Create vault" — writes the encrypted vault file
- "Unlock vault" (when the file exists) — master password only
**Unlocked:**
- List of entries: favicon + domain + username + reveal / copy / delete
- "Add new entry": domain, username, password (paste) OR "Generate" button
- "Lock now" at the top-right
## IPC surface (through settings-preload)
```
password-status() → { setup: bool, unlocked: bool }
password-setup(masterPw, seedSource) → { ok: true } | { err }
password-unlock(masterPw) → { ok: true, entries: [...] } | { err: "bad password" }
password-lock() → true
password-list() → array of entries (metadata only)
password-get(id) → { password: <plaintext> } (only while unlocked)
password-add(entry) → id
password-update(id, patch) → true
password-remove(id) → true
password-generate({ domain, username, version, rules }) → <plaintext string>
```
Renderers NEVER see the seed / purposeRoot / vault key / master password
past the unlock call.
## Phase 2 — autofill + Sia backup
- **Autofill**: contentScript watches `input[type=password]` on load,
binds by (public-suffix-list-derived) eTLD+1 origin so `evil-google.com`
can't fill `google.com` entries. Toolbar key icon + right-click "Fill
password" menu.
- **Sia backup**: user provides a Sia S3 endpoint + credentials (or reuses
the operator relay). Vault encrypted-blob is uploaded on save; restored
on new device by pointing at the same endpoint with the master password.
## Phase 3 — unified identity
- Same seed → Nostr messaging keys under `m/1414'/0'`. Compatible with all
Nostr clients (secp256k1 keys, npub/nsec encoding).
- `window.bcnr` provider spec (Web3-style) exposing signed BCNR name
operations to Silent Mode pages — decision to be made per SECURITY.md.
## Explicitly not doing
- Cloud sync via anything but Sia. No opinionated third-party.
- Chromium's password autofill UI. Its ergonomics are Google-Sync-shaped
and don't fit our threat model.
- Silent-Mode-only browser extension. The whole thing is built-in — no
install / no separate origin / no extension permissions to grant.

View file

@ -197,7 +197,10 @@
T.navigate(v); T.navigate(v);
} }
$("url").addEventListener("keydown", (e) => { if (e.key === "Enter") goURL(); }); $("url").addEventListener("keydown", (e) => { if (e.key === "Enter") goURL(); });
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) { T.search(q); $("search").value = ""; } } }); // Enter submits the search; keep the query visible so the user can refine
// it or search again — clearing it on submit lost context and made refining
// annoying (esp. when the engine's own results page uses its own search box).
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } });
$("back").onclick = () => T.back(); $("back").onclick = () => T.back();
$("fwd").onclick = () => T.forward(); $("fwd").onclick = () => T.forward();
$("reload").onclick = () => T.reload(); $("reload").onclick = () => T.reload();

230
main.js
View file

@ -21,6 +21,15 @@ const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
const RESOLVER = app.isPackaged const RESOLVER = app.isPackaged
? path.join(RES_DIR, "resolver-web.mjs") ? path.join(RES_DIR, "resolver-web.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js"); : path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
// Password vault — same .mjs-in-resources pattern as the resolver.
const VAULT_MOD = app.isPackaged
? path.join(RES_DIR, "password-vault.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "password-vault.js");
let vaultLib;
async function loadVaultLib() {
if (!vaultLib) vaultLib = await import(`file://${VAULT_MOD.replace(/\\/g, "/")}`);
return vaultLib;
}
// Built-in engines. Users can also add their own (settings.customEngines, // Built-in engines. Users can also add their own (settings.customEngines,
// each { id, name, url } where the url contains "%s" for the query). // each { id, name, url } where the url contains "%s" for the query).
// Catalog of built-in engines (users pick which to enable + can add their own). // Catalog of built-in engines (users pick which to enable + can add their own).
@ -32,7 +41,7 @@ const RESOLVER = app.isPackaged
// URL routing is identical for all — kind and tier are display-only grouping. // URL routing is identical for all — kind and tier are display-only grouping.
const SEARCH_ENGINES = { const SEARCH_ENGINES = {
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) }, duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) }, google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q, h = {}) => `https://www.google.com/search?q=${encodeURIComponent(q)}&hl=${h.hl || "en"}&gl=${h.gl || "us"}&pws=0` },
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) }, brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) }, bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) }, startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
@ -94,10 +103,26 @@ function allEngines() {
}); });
} }
function enabledEnginesList() { return allEngines().filter((e) => e.enabled); } function enabledEnginesList() { return allEngines().filter((e) => e.enabled); }
// Region → 2-letter country code, for engines that accept a `gl`-style hint
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
// a consent redirect or the region-detect start page instead of results).
const REGION_TO_COUNTRY = {
europe: "de", asia: "jp", north_america: "us", south_america: "br",
africa: "ke", middle_east: "ae", australia: "au",
};
function searchHints() {
const loc = effLocale(); // e.g. "en-US" (or null → show real)
const region = settings.locationMode === "spoof" ? settings.locationRegion : null;
return {
hl: (loc || app.getLocale() || "en").split("-")[0],
gl: REGION_TO_COUNTRY[region] || (loc && loc.split("-")[1]?.toLowerCase()) || "us",
};
}
function engineUrl(id, q) { function engineUrl(id, q) {
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q); const h = searchHints();
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q, h);
const c = (settings.customEngines || []).find((e) => e.id === id); const c = (settings.customEngines || []).find((e) => e.id === id);
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q); return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q, h);
} }
const SEARCH = (q) => engineUrl(settings.searchEngine, q); const SEARCH = (q) => engineUrl(settings.searchEngine, q);
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys. // Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
@ -158,6 +183,12 @@ const SETTINGS_DEFAULTS = {
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
restoreSession: true, // reopen last session's tabs on launch restoreSession: true, // reopen last session's tabs on launch
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
// Storage retention — nothing persists by default. Auto-clear on quit
// means a session leaves no trace on disk unless the user opts in per-type.
clearCookiesOnQuit: true, // drop cookies + logins + saved-form data
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit: true, // drop navigation history + saved tabs
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value) // Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
@ -337,6 +368,30 @@ async function applyFingerprint(wc) {
} catch { /* debugger busy (e.g. devtools) — best effort */ } } catch { /* debugger busy (e.g. devtools) — best effort */ }
} }
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); } function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
// cache, localStorage, IndexedDB, service workers, cache API by default.
// 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 } = {}) {
const ses = session.defaultSession;
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
const storages = [];
if (cookies) storages.push("cookies");
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
if (storages.length) {
try { await ses.clearStorageData({ storages }); }
catch (e) { console.warn("clearStorageData:", e.message); }
}
// navigation history lives in each webContents; drop it too when history-clear was asked.
// (called separately by the before-quit hook, since history-clear also deletes session.json)
}
async function clearHistoryNow() {
for (const t of tabs) {
try { t.view.webContents.navigationHistory.clear(); } catch {}
}
try { fs.unlinkSync(sessionFile()); } catch {}
}
// Accept-Language header follows the locale setting (session-wide, best effort). // Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage() { function applyAcceptLanguage() {
const loc = effLocale() || app.getLocale() || "en-US"; const loc = effLocale() || app.getLocale() || "en-US";
@ -579,7 +634,19 @@ async function serveBns(request) {
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); } if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } }); if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
const r = rec.entry.records; const r = rec.entry.records;
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
// whole namespace via Host routing; `s3` semantics are exact-key per name.
// For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the
// apex (host === entry.name), current priority stands. See public-gateway.mjs
// for the full argument; keep this in step with that file.
const isSubdomain = host !== rec.entry.name;
const serveIp = async () => {
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
};
try { try {
if (isSubdomain && r.ip) return await serveIp();
if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); } if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); }
if (r.s3) { if (r.s3) {
// Secret-free: fetch Sia content from the public gateway (it holds the // Secret-free: fetch Sia content from the public gateway (it holds the
@ -596,10 +663,7 @@ async function serveBns(request) {
} }
return new Response(body, { status: up.status, headers: { "content-type": ct } }); return new Response(body, { status: up.status, headers: { "content-type": ct } });
} }
if (r.ip) { if (r.ip) return await serveIp();
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
}
if (r.u) return Response.redirect(r.u, 302); if (r.u) return Response.redirect(r.u, 302);
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } }); return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); } } catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
@ -1032,7 +1096,15 @@ async function loadBns(t, id, host, rest, tld) {
} }
await t.view.webContents.loadURL(`bns://${host}${rest}`); await t.view.webContents.loadURL(`bns://${host}${rest}`);
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record"; // Source badge must mirror what serveBns actually picks — subdomain-with-ip
// routes via the parent's server, not via Sia. See serveBns for the rule.
const _isSub = host !== entry.name;
const src = (_isSub && entry.records.ip) ? "direct server"
: entry.records.h ? "on-chain (chain)"
: entry.records.s3 ? "Sia network"
: entry.records.ip ? "direct server"
: entry.records.u ? "redirect"
: "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry }; t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
if (id === activeId) pushNav(t.prov); if (id === activeId) pushNav(t.prov);
emitTabs(); emitTabs();
@ -1087,7 +1159,14 @@ ipcMain.handle("collision-switch", async (_e, arg) => {
await t.view.webContents.loadURL(`bns://${host}/`); await t.view.webContents.loadURL(`bns://${host}/`);
const rec = entries.get(host); const rec = entries.get(host);
const r = rec?.entry?.records || {}; const r = rec?.entry?.records || {};
const src = r.h ? "on-chain (chain)" : r.s3 ? "Sia network" : r.ip ? "direct server" : r.u ? "redirect" : "record"; // Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
const _isSub = rec?.entry?.name && host !== rec.entry.name;
const src = (_isSub && r.ip) ? "direct server"
: r.h ? "on-chain (chain)"
: r.s3 ? "Sia network"
: r.ip ? "direct server"
: r.u ? "redirect"
: "record";
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry }; t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry };
} }
} catch (e) { console.warn("collision-switch load failed:", e?.message); } } catch (e) { console.warn("collision-switch load failed:", e?.message); }
@ -1106,6 +1185,120 @@ ipcMain.handle("collision-set-policy", (_e, p) => {
return settings.collisionPolicy; return settings.collisionPolicy;
}); });
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; }); ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
// Storage: clear right now (any subset). "history" also drops the saved-session file.
// ---- Password vault -------------------------------------------------------
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
// held in this main-process closure only — never sent to a renderer except
// in the explicit response to password-get(id). Cleared on quit alongside
// the other storage clears (see before-quit hook).
const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault");
let vaultState = null; // { key, purposeRoot, entries, _salt, _iters }
const vaultOk = () => ({ ok: true });
const vaultErr = (m) => ({ ok: false, err: String(m) });
ipcMain.handle("password-status", () => ({
setup: fs.existsSync(vaultFile()),
unlocked: !!vaultState,
}));
ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => {
try {
if (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short");
if (fs.existsSync(vaultFile())) return vaultErr("vault already exists");
const v = await loadVaultLib();
let purposeRootHex;
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
const seed = await v.bip39ToSeed(String(seedSource.mnemonic));
const root = await v.seedToPurposeRoot(seed, "passwords/0");
purposeRootHex = v.bytesToHex(root);
} else {
// Independent random seed — 32 bytes of purposeRoot directly.
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
purposeRootHex = v.bytesToHex(root);
}
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex);
return vaultOk();
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-unlock", async (_e, masterPassword) => {
try {
if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
const v = await loadVaultLib();
vaultState = await v.unlockVault(vaultFile(), masterPassword);
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-lock", () => { vaultState = null; return true; });
ipcMain.handle("password-list", async () => {
if (!vaultState) return { ok: false, err: "locked" };
const v = await loadVaultLib();
return { ok: true, entries: v.listMetadata(vaultState) };
});
ipcMain.handle("password-get", async (_e, id) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const password = await v.resolvePassword(vaultState, id);
return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-add", async (_e, spec) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const entry = v.newEntry(spec || {});
vaultState.entries.push(entry);
await v.saveVault(vaultFile(), vaultState);
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-update", async (_e, id, patch) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const e = vaultState.entries.find((x) => x.id === id);
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];
// 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);
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-remove", async (_e, id) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
await v.saveVault(vaultFile(), vaultState);
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-generate", async (_e, { domain, username = "", version = 1, rules } = {}) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const password = await v.derivePassword(vaultState.purposeRoot, { domain, username, version, rules });
return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("clear-browsing-data", async (_e, opts) => {
const o = opts || {};
await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage });
if (o.history) await clearHistoryNow();
return true;
});
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine })); ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
ipcMain.handle("set-search-engine", (_e, id) => { ipcMain.handle("set-search-engine", (_e, id) => {
if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); } if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
@ -1292,7 +1485,24 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
}); });
app.on("before-quit", () => { saveSession(); stopTor(); }); app.on("before-quit", async (e) => {
// Auto-clear per user settings. saveSession() runs first so restoreSession
// still works UNLESS the user asked to drop history — in which case we
// wipe the session file too so the next launch is genuinely blank.
saveSession();
stopTor();
vaultState = null; // drop the in-memory vault key + purposeRoot
try {
await clearBrowsingData({
cookies: settings.clearCookiesOnQuit,
cache: settings.clearCacheOnQuit,
storage: settings.clearStorageOnQuit,
});
if (settings.clearHistoryOnQuit) {
try { fs.unlinkSync(sessionFile()); } catch {}
}
} catch (err) { console.error("before-quit clear failed:", err?.message); }
});
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); }); app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
} }

View file

@ -51,7 +51,8 @@
], ],
"extraResources": [ "extraResources": [
{ "from": "tor", "to": "tor" }, { "from": "tor", "to": "tor" },
{ "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" } { "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" },
{ "from": "../Argus/src/lib/password-vault.js", "to": "password-vault.mjs" }
], ],
"win": { "target": ["nsis", "portable"] }, "win": { "target": ["nsis", "portable"] },
"nsis": { "nsis": {

View file

@ -8,6 +8,22 @@ contextBridge.exposeInMainWorld("cfg", {
setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on), setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on),
setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids), setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids),
removeFromList: (id) => ipcRenderer.invoke("remove-from-list", id), removeFromList: (id) => ipcRenderer.invoke("remove-from-list", id),
// Storage: wipe browsing data on demand. Pass any subset of
// { cookies, cache, storage, history }.
clearBrowsingData: (opts) => ipcRenderer.invoke("clear-browsing-data", opts),
// Password vault. All calls return { ok, ... } | { ok: false, err }.
// Renderers never see the seed / vault key / master password past setup/
// unlock; get() returns plaintext only in explicit response to a user click.
pwStatus: () => ipcRenderer.invoke("password-status"),
pwSetup: (masterPassword, seedSource) => ipcRenderer.invoke("password-setup", { masterPassword, seedSource }),
pwUnlock: (masterPassword) => ipcRenderer.invoke("password-unlock", masterPassword),
pwLock: () => ipcRenderer.invoke("password-lock"),
pwList: () => ipcRenderer.invoke("password-list"),
pwGet: (id) => ipcRenderer.invoke("password-get", id),
pwAdd: (entry) => ipcRenderer.invoke("password-add", entry),
pwUpdate: (id, patch) => ipcRenderer.invoke("password-update", id, patch),
pwRemove: (id) => ipcRenderer.invoke("password-remove", id),
pwGenerate: (spec) => ipcRenderer.invoke("password-generate", spec),
// Main asks settings to jump to a specific sidebar section (e.g. from the // Main asks settings to jump to a specific sidebar section (e.g. from the
// engine picker's "Search settings…" click). Emits the section id string. // engine picker's "Search settings…" click). Emits the section id string.
onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)), onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)),

View file

@ -144,6 +144,7 @@
<div class="brand">⛓ Theseus</div> <div class="brand">⛓ Theseus</div>
<a data-sec="general" class="active">General</a> <a data-sec="general" class="active">General</a>
<a data-sec="search">Search</a> <a data-sec="search">Search</a>
<a data-sec="passwords">Passwords</a>
<a data-sec="naming">Registries</a> <a data-sec="naming">Registries</a>
<a data-sec="performance">Performance</a> <a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a> <a data-sec="privacy">Privacy</a>
@ -215,6 +216,60 @@
</div> </div>
</div> </div>
</section> </section>
<!-- PASSWORDS -->
<section id="passwords" hidden>
<h1>Passwords</h1>
<p class="lede">Local password vault. Set once, unlocked with a master password. Encrypted at rest; nothing leaves your machine.</p>
<!-- State A: no vault yet — set up -->
<div id="pwSetup" hidden>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt"><div class="t">Master password</div>
<div class="d">Used to unlock the vault every session. This is separate from your Ariadne wallet passphrase — memorize it, we can't recover it.</div></div>
<div class="addeng"><input id="pwSetupPw1" type="password" placeholder="Master password"><input id="pwSetupPw2" type="password" placeholder="Confirm"></div>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt"><div class="t">Seed for deterministic passwords</div>
<div class="d">The "Generate" button in an entry derives a password from this seed. Same seed on another device → same passwords for the same site + username.</div></div>
<div style="display:flex;flex-direction:column;gap:6px">
<label class="polrow"><input type="radio" name="pwSeedSource" value="mnemonic" checked><span><b>Use my Ariadne wallet mnemonic</b> <span class="pmuted">— unified identity, one seed to back up</span></span></label>
<label class="polrow"><input type="radio" name="pwSeedSource" value="generate"><span><b>Generate a new independent seed</b> <span class="pmuted">— isolated from any BCH funds</span></span></label>
</div>
<textarea id="pwSetupMnemonic" placeholder="12 or 24 BIP39 words separated by spaces" rows="3" style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;font-family:ui-monospace,monospace;outline:none;resize:vertical"></textarea>
<div class="pmuted" style="font-size:12px">The mnemonic is used only to derive the password-purpose subtree (m/1381'/0'). It is not stored — only the derived subtree key is persisted, encrypted with your master password.</div>
</div>
<div class="row" style="justify-content:flex-end">
<button id="pwSetupBtn" class="btn" type="button">Create vault</button>
</div>
</div>
<!-- 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>
<!-- State C: vault unlocked -->
<div id="pwUnlocked" hidden>
<div class="row" style="justify-content:space-between">
<div class="txt"><div class="t">Your passwords</div><div class="d">Reveal, copy, or edit any entry. The vault re-locks when Theseus quits.</div></div>
<button id="pwLockBtn" class="btn" type="button">Lock now</button>
</div>
<div id="pwList"></div>
<h2 class="sub">Add an entry</h2>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="addeng"><input id="pwAddDomain" placeholder="Site (e.g. github.com)"><input id="pwAddUser" placeholder="Username or email"></div>
<div style="display:flex;flex-direction:column;gap:6px">
<label class="polrow"><input type="radio" name="pwAddKind" value="generated" checked><span><b>Generate deterministically</b> <span class="pmuted">— derived from your seed; same across devices</span></span></label>
<label class="polrow"><input type="radio" name="pwAddKind" value="literal"><span><b>Paste an existing password</b> <span class="pmuted">— for legacy accounts you already set elsewhere</span></span></label>
</div>
<input id="pwAddLiteral" type="password" placeholder="Paste password" hidden>
<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">There is no autofill yet (phase 2). Copy the password from an entry and paste it into the site.</div>
</div>
</section>
<!-- NAMING --> <!-- NAMING -->
<section id="naming" hidden> <section id="naming" hidden>
<h1>Registries</h1> <h1>Registries</h1>
@ -277,12 +332,39 @@
<div class="txt"><div class="t">Timezone</div><div class="d">What sites read via JavaScript (Intl / Date).</div></div> <div class="txt"><div class="t">Timezone</div><div class="d">What sites read via JavaScript (Intl / Date).</div></div>
<div class="ctl"> <div class="ctl">
<select id="timezoneMode"><option value="show">Show real</option><option value="hide">Hide (UTC)</option><option value="spoof">Spoof (auto)</option><option value="manual">Manual…</option></select> <select id="timezoneMode"><option value="show">Show real</option><option value="hide">Hide (UTC)</option><option value="spoof">Spoof (auto)</option><option value="manual">Manual…</option></select>
<input id="timezoneValue" list="tzList" placeholder="pick or type e.g. Europe/Berlin" hidden> <!-- Native <select> instead of an <input list=""> datalist — the
<datalist id="tzList"> datalist popup was flaky in Electron and never rendered on some
<option value="UTC"><option value="Europe/London"><option value="Europe/Berlin"><option value="Europe/Paris"><option value="Europe/Moscow"> displays; a real select is unambiguous. -->
<option value="America/New_York"><option value="America/Chicago"><option value="America/Los_Angeles"><option value="America/Sao_Paulo"> <select id="timezoneValue" hidden>
<option value="Asia/Tokyo"><option value="Asia/Shanghai"><option value="Asia/Kolkata"><option value="Asia/Dubai"><option value="Australia/Sydney"><option value="Africa/Nairobi"> <option value="UTC">UTC</option>
</datalist> <option value="Europe/London">Europe/London</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Europe/Madrid">Europe/Madrid</option>
<option value="Europe/Rome">Europe/Rome</option>
<option value="Europe/Moscow">Europe/Moscow</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="America/Sao_Paulo">America/Sao_Paulo</option>
<option value="America/Mexico_City">America/Mexico_City</option>
<option value="America/Toronto">America/Toronto</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Seoul">Asia/Seoul</option>
<option value="Asia/Kolkata">Asia/Kolkata</option>
<option value="Asia/Dubai">Asia/Dubai</option>
<option value="Asia/Singapore">Asia/Singapore</option>
<option value="Asia/Bangkok">Asia/Bangkok</option>
<option value="Australia/Sydney">Australia/Sydney</option>
<option value="Australia/Perth">Australia/Perth</option>
<option value="Africa/Nairobi">Africa/Nairobi</option>
<option value="Africa/Cairo">Africa/Cairo</option>
<option value="Africa/Johannesburg">Africa/Johannesburg</option>
<option value="__other__">Other…</option>
</select>
<input id="timezoneValueOther" type="text" placeholder="IANA zone, e.g. America/Anchorage" hidden>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -301,11 +383,38 @@
<option value="de-DE">German (Deutsch)</option> <option value="de-DE">German (Deutsch)</option>
<option value="ja-JP">Japanese (日本語)</option> <option value="ja-JP">Japanese (日本語)</option>
</select> </select>
<input id="languageValue" list="langList" placeholder="pick or type e.g. it-IT" hidden> <!-- Native <select> (same reason as timezone above). -->
<datalist id="langList"> <select id="languageValue" hidden>
<option value="en-US"><option value="en-GB"><option value="zh-CN"><option value="es-ES"><option value="hi-IN"><option value="ar"> <option value="en-US">English (US) — en-US</option>
<option value="pt-BR"><option value="ru-RU"><option value="fr-FR"><option value="de-DE"><option value="ja-JP"><option value="it-IT"><option value="ko-KR"><option value="nl-NL"><option value="tr-TR"> <option value="en-GB">English (UK) — en-GB</option>
</datalist> <option value="zh-CN">Chinese (Simplified) — zh-CN</option>
<option value="zh-TW">Chinese (Traditional) — zh-TW</option>
<option value="es-ES">Spanish (Spain) — es-ES</option>
<option value="es-MX">Spanish (Mexico) — es-MX</option>
<option value="pt-BR">Portuguese (Brazil) — pt-BR</option>
<option value="pt-PT">Portuguese (Portugal) — pt-PT</option>
<option value="fr-FR">French — fr-FR</option>
<option value="de-DE">German — de-DE</option>
<option value="it-IT">Italian — it-IT</option>
<option value="nl-NL">Dutch — nl-NL</option>
<option value="ru-RU">Russian — ru-RU</option>
<option value="ja-JP">Japanese — ja-JP</option>
<option value="ko-KR">Korean — ko-KR</option>
<option value="hi-IN">Hindi — hi-IN</option>
<option value="ar">Arabic — ar</option>
<option value="tr-TR">Turkish — tr-TR</option>
<option value="pl-PL">Polish — pl-PL</option>
<option value="uk-UA">Ukrainian — uk-UA</option>
<option value="sv-SE">Swedish — sv-SE</option>
<option value="fi-FI">Finnish — fi-FI</option>
<option value="el-GR">Greek — el-GR</option>
<option value="he-IL">Hebrew — he-IL</option>
<option value="vi-VN">Vietnamese — vi-VN</option>
<option value="th-TH">Thai — th-TH</option>
<option value="id-ID">Indonesian — id-ID</option>
<option value="__other__">Other…</option>
</select>
<input id="languageValueOther" type="text" placeholder="BCP-47 tag, e.g. cs-CZ" hidden>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -321,24 +430,78 @@
<option value="middle_east">Middle East</option> <option value="middle_east">Middle East</option>
<option value="australia">Australia</option> <option value="australia">Australia</option>
</select> </select>
<div class="coords" id="locationCoords" hidden> <!-- Manual location = pick a city from a native select; its lat/lon
<input id="locationCity" list="cityList" placeholder="pick a city…" style="width:112px"> are looked up client-side (see CITIES below) and written to
<input id="locationLat" placeholder="lat"><input id="locationLon" placeholder="lon"> locationLat / locationLon settings, which drive the
navigator.geolocation override in main.js. The raw lat/lon
inputs used to sit here but nobody types coordinates by hand;
the city picker gives the same override with a single click. -->
<select id="locationCity" hidden>
<option value="">Pick a city…</option>
<option value="london">London</option>
<option value="berlin">Berlin</option>
<option value="paris">Paris</option>
<option value="madrid">Madrid</option>
<option value="rome">Rome</option>
<option value="moscow">Moscow</option>
<option value="istanbul">Istanbul</option>
<option value="dubai">Dubai</option>
<option value="mumbai">Mumbai</option>
<option value="singapore">Singapore</option>
<option value="bangkok">Bangkok</option>
<option value="shanghai">Shanghai</option>
<option value="tokyo">Tokyo</option>
<option value="seoul">Seoul</option>
<option value="sydney">Sydney</option>
<option value="new_york">New York</option>
<option value="los_angeles">Los Angeles</option>
<option value="chicago">Chicago</option>
<option value="toronto">Toronto</option>
<option value="mexico_city">Mexico City</option>
<option value="sao_paulo">São Paulo</option>
<option value="buenos_aires">Buenos Aires</option>
<option value="cairo">Cairo</option>
<option value="nairobi">Nairobi</option>
<option value="johannesburg">Johannesburg</option>
<option value="__other__">Other…</option>
</select>
<div id="locationOther" class="coords" hidden>
<input id="locationLatOther" type="number" step="0.0001" placeholder="lat" style="width:100px">
<input id="locationLonOther" type="number" step="0.0001" placeholder="lon" style="width:100px">
</div> </div>
<datalist id="cityList">
<option value="London"><option value="Berlin"><option value="Paris"><option value="Moscow"><option value="New York"><option value="Los Angeles">
<option value="São Paulo"><option value="Tokyo"><option value="Shanghai"><option value="Mumbai"><option value="Dubai"><option value="Sydney"><option value="Nairobi"><option value="Singapore">
</datalist>
</div> </div>
</div> </div>
<div class="note">These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div> <div class="note">These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div>
<h2 class="sub">Storage</h2>
<p class="subd">By default Theseus keeps <b>nothing</b> across sessions — everything toggled on here is wiped when you quit. Untoggle a bucket to keep it (e.g. cookies to stay signed in on trusted sites).</p>
<div class="row">
<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">
<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>
</div>
<div class="row">
<div class="txt"><div class="t">Clear site storage on quit</div><div class="d">Drops localStorage, IndexedDB, service workers, and the cache API. Web-app state resets.</div></div>
<label class="sw"><input type="checkbox" id="clearStorageOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Clear history on quit</div><div class="d">Drops navigation history + the saved-tabs session file (overrides "Reopen previous tabs").</div></div>
<label class="sw"><input type="checkbox" id="clearHistoryOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" style="justify-content:flex-end">
<button id="clearNow" class="btn" type="button">Clear all now</button>
</div>
<div class="note">There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).</div>
</section> </section>
</div> </div>
</div> </div>
<script> <script>
const C = window.cfg; const C = window.cfg;
// sidebar navigation // sidebar navigation
const sections = ["general", "search", "naming", "performance", "privacy"]; const sections = ["general", "search", "passwords", "naming", "performance", "privacy"];
function showSection(sec) { function showSection(sec) {
if (!sections.includes(sec)) return; if (!sections.includes(sec)) return;
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec)); document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
@ -349,7 +512,8 @@
// click routes to the Search section instead of the General default). // click routes to the Search section instead of the General default).
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec)); if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices"]; const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];
C.get().then((s) => { C.get().then((s) => {
for (const k of TOGGLES) { for (const k of TOGGLES) {
const el = document.getElementById(k); if (!el) continue; const el = document.getElementById(k); if (!el) continue;
@ -538,37 +702,126 @@
apply(m.value === "manual"); apply(m.value === "manual");
m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); }); m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); });
}; };
const val = (id) => { const v = document.getElementById(id); v.value = s[id] ?? ""; v.addEventListener("change", () => C.set(id, v.value.trim())); return v; }; // Value fields: <select> for tz + lang manual mode, <select> city for
const tzV = val("timezoneValue"), lgV = val("languageValue"); // location manual mode. Each has an "Other…" sentinel at the end that
val("locationLat"); val("locationLon"); // reveals a text input so the user can enter a value not in the built-in
bind("timezoneMode", null, (manual) => tzV.hidden = !manual); // list (any IANA zone, any BCP-47 locale). selectWithOther handles the
// language: top-10 dropdown when spoofing, free-text locale when manual // round-trip: if a saved value isn't in the predefined options, "Other"
// is auto-selected on load and the input pre-fills with that value.
function selectWithOther(selectId, otherInputId, settingsKey = selectId) {
const sel = document.getElementById(selectId);
const inp = document.getElementById(otherInputId);
const saved = s[settingsKey] ?? "";
const known = new Set([...sel.options].map((o) => o.value).filter((v) => v && v !== "__other__"));
const isCustom = saved && !known.has(saved);
sel.value = isCustom ? "__other__" : saved;
if (isCustom) inp.value = saved;
const applyVis = () => { inp.hidden = sel.value !== "__other__"; };
applyVis();
sel.addEventListener("change", () => {
if (sel.value === "__other__") { applyVis(); setTimeout(() => inp.focus(), 0); return; }
C.set(settingsKey, sel.value); applyVis();
});
inp.addEventListener("change", () => { const v = String(inp.value).trim(); if (v) C.set(settingsKey, v); });
return sel;
}
const tzV = selectWithOther("timezoneValue", "timezoneValueOther");
const lgV = selectWithOther("languageValue", "languageValueOther");
const tzOther = document.getElementById("timezoneValueOther");
const lgOther = document.getElementById("languageValueOther");
// "Manual" mode reveals both the select AND the Other input (if Other was picked).
const applyTzVis = (manual) => { tzV.hidden = !manual; tzOther.hidden = !manual || tzV.value !== "__other__"; };
bind("timezoneMode", null, applyTzVis);
// language: fixed dropdown when spoofing (top 10), broader dropdown when manual
const lngMode = document.getElementById("languageMode"); const lngMode = document.getElementById("languageMode");
const lngSpoof = document.getElementById("languageSpoof"); const lngSpoof = document.getElementById("languageSpoof");
lngSpoof.value = s.languageSpoof || "en-US"; lngSpoof.value = s.languageSpoof || "en-US";
lngSpoof.addEventListener("change", () => C.set("languageSpoof", lngSpoof.value)); lngSpoof.addEventListener("change", () => C.set("languageSpoof", lngSpoof.value));
const applyLng = () => { lngSpoof.hidden = lngMode.value !== "spoof"; lgV.hidden = lngMode.value !== "manual"; }; const applyLng = () => {
lngSpoof.hidden = lngMode.value !== "spoof";
lgV.hidden = lngMode.value !== "manual";
lgOther.hidden = lngMode.value !== "manual" || lgV.value !== "__other__";
};
lngMode.value = s.languageMode || "show"; lngMode.value = s.languageMode || "show";
applyLng(); applyLng();
lngMode.addEventListener("change", () => { C.set("languageMode", lngMode.value); applyLng(); }); lngMode.addEventListener("change", () => { C.set("languageMode", lngMode.value); applyLng(); });
// location: region dropdown when spoofing, exact coords when manual // location: region dropdown when spoofing, city dropdown when manual.
// Manual writes lat/lon into settings via the CITIES map below; the raw
// coord inputs were dropped from the UI (nobody types them by hand).
const locMode = document.getElementById("locationMode"); const locMode = document.getElementById("locationMode");
const locRegion = document.getElementById("locationRegion"); const locRegion = document.getElementById("locationRegion");
const locCoords = document.getElementById("locationCoords"); const locCity = document.getElementById("locationCity");
locRegion.value = s.locationRegion || "europe"; locRegion.value = s.locationRegion || "europe";
locRegion.addEventListener("change", () => C.set("locationRegion", locRegion.value)); locRegion.addEventListener("change", () => C.set("locationRegion", locRegion.value));
const applyLoc = () => { locRegion.hidden = locMode.value !== "spoof"; locCoords.hidden = locMode.value !== "manual"; }; // Location manual reveals the city select; picking "Other…" reveals a
// lat/lon pair, whose values are pushed to locationLat/Lon settings.
const locOther = document.getElementById("locationOther");
const locLatOther = document.getElementById("locationLatOther");
const locLonOther = document.getElementById("locationLonOther");
const applyLoc = () => {
locRegion.hidden = locMode.value !== "spoof";
locCity.hidden = locMode.value !== "manual";
locOther.hidden = locMode.value !== "manual" || locCity.value !== "__other__";
};
locMode.value = s.locationMode || "show"; locMode.value = s.locationMode || "show";
applyLoc(); applyLoc();
locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); }); locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); });
// picking a city fills the exact lat/lon fields locCity.addEventListener("change", applyLoc);
const CITY = { "London":[51.5074,-0.1278],"Berlin":[52.52,13.405],"Paris":[48.8566,2.3522],"Moscow":[55.7558,37.6173],"New York":[40.7128,-74.006],"Los Angeles":[34.0522,-118.2437],"São Paulo":[-23.5505,-46.6333],"Tokyo":[35.6762,139.6503],"Shanghai":[31.2304,121.4737],"Mumbai":[19.076,72.8777],"Dubai":[25.2048,55.2708],"Sydney":[-33.8688,151.2093],"Nairobi":[-1.2921,36.8219],"Singapore":[1.3521,103.8198] }; // Prefill Other lat/lon inputs from saved settings.
document.getElementById("locationCity").addEventListener("change", (e) => { locLatOther.value = s.locationLat ?? "";
const c = CITY[e.target.value.trim()]; if (!c) return; locLonOther.value = s.locationLon ?? "";
const la = document.getElementById("locationLat"), lo = document.getElementById("locationLon"); const saveOtherCoords = () => {
la.value = c[0]; lo.value = c[1]; C.set("locationLat", String(c[0])); C.set("locationLon", String(c[1])); const la = String(locLatOther.value).trim();
const lo = String(locLonOther.value).trim();
if (la !== "") C.set("locationLat", la);
if (lo !== "") C.set("locationLon", lo);
C.set("locationCity", "__other__");
};
locLatOther.addEventListener("change", saveOtherCoords);
locLonOther.addEventListener("change", saveOtherCoords);
// If the previously-saved city was "__other__", set the select to it so the
// Other inputs stay visible on reload.
if (s.locationCity === "__other__") locCity.value = "__other__";
// Picking a city writes its lat/lon to settings — that's what
// navigator.geolocation returns to pages once "manual" mode is active.
const CITIES = {
london:[51.5074,-0.1278], berlin:[52.52,13.405], paris:[48.8566,2.3522],
madrid:[40.4168,-3.7038], rome:[41.9028,12.4964], moscow:[55.7558,37.6173],
istanbul:[41.0082,28.9784], dubai:[25.2048,55.2708], mumbai:[19.076,72.8777],
singapore:[1.3521,103.8198], bangkok:[13.7563,100.5018], shanghai:[31.2304,121.4737],
tokyo:[35.6762,139.6503], seoul:[37.5665,126.978], sydney:[-33.8688,151.2093],
new_york:[40.7128,-74.006], los_angeles:[34.0522,-118.2437], chicago:[41.8781,-87.6298],
toronto:[43.6532,-79.3832], mexico_city:[19.4326,-99.1332], sao_paulo:[-23.5505,-46.6333],
buenos_aires:[-34.6037,-58.3816], cairo:[30.0444,31.2357], nairobi:[-1.2921,36.8219],
johannesburg:[-26.2041,28.0473],
};
// Restore prior city selection when possible by matching stored lat/lon.
const restoreCity = () => {
const la = Number(s.locationLat), lo = Number(s.locationLon);
for (const [key, [x, y]] of Object.entries(CITIES))
if (Math.abs(x - la) < 0.01 && Math.abs(y - lo) < 0.01) { locCity.value = key; return; }
};
restoreCity();
locCity.addEventListener("change", () => {
const c = CITIES[locCity.value]; if (!c) return;
C.set("locationLat", String(c[0]));
C.set("locationLon", String(c[1]));
C.set("locationCity", locCity.value); // save the pick so we can restore it later
}); });
// Storage: "Clear all now" wipes everything the toggles cover, without
// waiting for quit. Confirm first — this signs the user out of everything.
const clrBtn = document.getElementById("clearNow");
if (clrBtn) clrBtn.onclick = async () => {
if (!confirm("Clear cookies, cache, site storage, and history now?\n\nYou'll be signed out of everything and open tabs won't be restored.")) return;
clrBtn.disabled = true; clrBtn.textContent = "Clearing…";
try {
await C.clearBrowsingData({ cookies: true, cache: true, storage: true, history: true });
clrBtn.textContent = "Cleared ✓";
} catch (e) { clrBtn.textContent = "Clear failed"; console.error(e); }
setTimeout(() => { clrBtn.textContent = "Clear all now"; clrBtn.disabled = false; }, 1600);
};
// ---- Naming section: BCNR/ICANN collision policy + remembered choices ---- // ---- Naming section: BCNR/ICANN collision policy + remembered choices ----
function refreshCollisions() { function refreshCollisions() {
C.collisionState().then((cs) => { C.collisionState().then((cs) => {
@ -588,6 +841,125 @@
document.getElementById("resetCollisions").onclick = () => { document.getElementById("resetCollisions").onclick = () => {
C.resetCollisions().then(refreshCollisions); C.resetCollisions().then(refreshCollisions);
}; };
// ---- Passwords section: three states (setup / locked / unlocked) ---------
// The vault lives in main.js — this UI just calls IPC. No plaintext ever
// sits in this DOM except the value produced by a specific Show/Copy click.
const pwSetupEl = document.getElementById("pwSetup");
const pwLockedEl = document.getElementById("pwLocked");
const pwUnlockedEl = document.getElementById("pwUnlocked");
const pwListEl = document.getElementById("pwList");
function pwShow(which) {
pwSetupEl.hidden = which !== "setup";
pwLockedEl.hidden = which !== "locked";
pwUnlockedEl.hidden = which !== "unlocked";
}
async function pwRefresh() {
const st = await C.pwStatus();
if (!st.setup) return pwShow("setup");
if (!st.unlocked) return pwShow("locked");
pwShow("unlocked");
const res = await C.pwList();
renderPwList(res.ok ? res.entries : []);
}
function renderPwList(entries) {
if (!entries.length) {
pwListEl.innerHTML = `<div class="cempty" style="padding:12px 0">No entries yet — add one below.</div>`;
return;
}
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
`<span class="eic"><img class="ei" src="https://icons.duckduckgo.com/ip3/${esc(e.domain)}.ico" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'🔑'}))"></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 pwShow" title="Show + copy">👁</button>` +
`<button class="cx pwDel" title="Remove"></button>` +
`</div>`).join("");
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id;
const res = await C.pwGet(id);
if (!res.ok) return alert("Couldn't read: " + res.err);
try { await navigator.clipboard.writeText(res.password); }
catch { /* browser may block clipboard in dev — fall through to a prompt */ prompt("Password (copy manually):", res.password); return; }
b.textContent = "copied ✓"; setTimeout(() => (b.textContent = "👁"), 1600);
});
pwListEl.querySelectorAll(".pwDel").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id;
if (!confirm("Remove this entry?")) return;
await C.pwRemove(id); pwRefresh();
});
}
// Setup — create vault
document.querySelectorAll('input[name="pwSeedSource"]').forEach((r) => r.addEventListener("change", () => {
document.getElementById("pwSetupMnemonic").hidden = document.querySelector('input[name="pwSeedSource"]:checked').value !== "mnemonic";
}));
document.getElementById("pwSetupBtn").onclick = async () => {
const p1 = document.getElementById("pwSetupPw1").value;
const p2 = document.getElementById("pwSetupPw2").value;
if (!p1 || p1.length < 8) return alert("Master password must be at least 8 characters.");
if (p1 !== p2) return alert("Passwords don't match.");
const kind = document.querySelector('input[name="pwSeedSource"]:checked').value;
const seedSource = kind === "mnemonic"
? { kind: "mnemonic", mnemonic: document.getElementById("pwSetupMnemonic").value }
: { kind: "generate" };
if (kind === "mnemonic" && !seedSource.mnemonic.trim()) return alert("Paste your mnemonic or switch to 'Generate a new independent seed'.");
const res = await C.pwSetup(p1, seedSource);
if (!res.ok) return alert("Setup failed: " + res.err);
// Vault created AND unlocked by main. Clear the setup fields.
document.getElementById("pwSetupPw1").value = "";
document.getElementById("pwSetupPw2").value = "";
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
document.querySelectorAll('input[name="pwAddKind"]').forEach((r) => r.addEventListener("change", () => {
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
document.getElementById("pwAddLiteral").hidden = kind !== "literal";
document.getElementById("pwAddPreview").hidden = kind !== "generated";
document.getElementById("pwAddPreviewOut").hidden = kind !== "generated";
}));
document.getElementById("pwAddPreview").onclick = async () => {
const domain = document.getElementById("pwAddDomain").value.trim();
const username = document.getElementById("pwAddUser").value.trim();
if (!domain) return alert("Enter a site.");
const res = await C.pwGenerate({ domain, username });
if (!res.ok) return alert("Preview failed: " + res.err);
document.getElementById("pwAddPreviewOut").value = res.password;
};
document.getElementById("pwAddBtn").onclick = async () => {
const domain = document.getElementById("pwAddDomain").value.trim();
const username = document.getElementById("pwAddUser").value.trim();
if (!domain) return alert("Enter a site.");
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
const spec = { domain, username };
if (kind === "literal") {
const lit = document.getElementById("pwAddLiteral").value;
if (!lit) return alert("Paste the password to save.");
spec.literal = lit;
}
const res = await C.pwAdd(spec);
if (!res.ok) return alert("Add failed: " + res.err);
document.getElementById("pwAddDomain").value = "";
document.getElementById("pwAddUser").value = "";
document.getElementById("pwAddLiteral").value = "";
document.getElementById("pwAddPreviewOut").value = "";
pwRefresh();
};
// Initial state — decide which panel to show now, and every time the user
// switches to Passwords in the sidebar (so a lock elsewhere is reflected).
pwRefresh();
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
}); });
</script> </script>
</body> </body>