From 0888048aceca1b0b7dffd836083cc4c310b89325 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 14 Aug 2026 23:17:18 +0200 Subject: [PATCH] Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DESIGN-password-manager.md | 209 ++++++++++++++++++ chrome.html | 5 +- main.js | 230 ++++++++++++++++++- package.json | 3 +- settings-preload.js | 16 ++ settings.html | 442 ++++++++++++++++++++++++++++++++++--- 6 files changed, 858 insertions(+), 47 deletions(-) create mode 100644 DESIGN-password-manager.md diff --git a/DESIGN-password-manager.md b/DESIGN-password-manager.md new file mode 100644 index 0000000..14b56e3 --- /dev/null +++ b/DESIGN-password-manager.md @@ -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: }, + iv: <12-byte hex>, + ciphertext: , // 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: , // 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=, 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: `/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: } (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. diff --git a/chrome.html b/chrome.html index 82bd83c..ccc5f8e 100644 --- a/chrome.html +++ b/chrome.html @@ -197,7 +197,10 @@ T.navigate(v); } $("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(); $("fwd").onclick = () => T.forward(); $("reload").onclick = () => T.reload(); diff --git a/main.js b/main.js index 3bf446b..d3f9c70 100644 --- a/main.js +++ b/main.js @@ -21,6 +21,15 @@ const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname; const RESOLVER = app.isPackaged ? path.join(RES_DIR, "resolver-web.mjs") : 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, // 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). @@ -32,7 +41,7 @@ const RESOLVER = app.isPackaged // URL routing is identical for all — kind and tier are display-only grouping. const SEARCH_ENGINES = { 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) }, 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) }, @@ -94,10 +103,26 @@ function allEngines() { }); } 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) { - 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); - 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); // 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 restoreSession: true, // reopen last session's tabs on launch 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) timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual 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 */ } } 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). function applyAcceptLanguage() { 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) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } }); 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 { + 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.s3) { // 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 } }); } - if (r.ip) { - 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.ip) return await serveIp(); if (r.u) return Response.redirect(r.u, 302); 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 }); } @@ -1032,7 +1096,15 @@ async function loadBns(t, id, host, rest, tld) { } 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 }; if (id === activeId) pushNav(t.prov); emitTabs(); @@ -1087,7 +1159,14 @@ ipcMain.handle("collision-switch", async (_e, arg) => { await t.view.webContents.loadURL(`bns://${host}/`); const rec = entries.get(host); 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 }; } } catch (e) { console.warn("collision-switch load failed:", e?.message); } @@ -1106,6 +1185,120 @@ ipcMain.handle("collision-set-policy", (_e, p) => { return settings.collisionPolicy; }); 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("set-search-engine", (_e, id) => { 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 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(); }); } diff --git a/package.json b/package.json index e868d10..fe405af 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ ], "extraResources": [ { "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"] }, "nsis": { diff --git a/settings-preload.js b/settings-preload.js index d6dd025..bb8f202 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -8,6 +8,22 @@ contextBridge.exposeInMainWorld("cfg", { setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on), setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids), 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 // engine picker's "Search settings…" click). Emits the section id string. onFocusSection: (cb) => ipcRenderer.on("focus-section", (_e, section) => cb(section)), diff --git a/settings.html b/settings.html index 7340a37..b992ab3 100644 --- a/settings.html +++ b/settings.html @@ -144,6 +144,7 @@ <div class="brand">⛓ Theseus</div> <a data-sec="general" class="active">General</a> <a data-sec="search">Search</a> + <a data-sec="passwords">Passwords</a> <a data-sec="naming">Registries</a> <a data-sec="performance">Performance</a> <a data-sec="privacy">Privacy</a> @@ -215,6 +216,60 @@ </div> </div> </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 --> <section id="naming" hidden> <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="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> - <input id="timezoneValue" list="tzList" placeholder="pick or type e.g. Europe/Berlin" hidden> - <datalist id="tzList"> - <option value="UTC"><option value="Europe/London"><option value="Europe/Berlin"><option value="Europe/Paris"><option value="Europe/Moscow"> - <option value="America/New_York"><option value="America/Chicago"><option value="America/Los_Angeles"><option value="America/Sao_Paulo"> - <option value="Asia/Tokyo"><option value="Asia/Shanghai"><option value="Asia/Kolkata"><option value="Asia/Dubai"><option value="Australia/Sydney"><option value="Africa/Nairobi"> - </datalist> + <!-- Native <select> instead of an <input list=""> datalist — the + datalist popup was flaky in Electron and never rendered on some + displays; a real select is unambiguous. --> + <select id="timezoneValue" hidden> + <option value="UTC">UTC</option> + <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 class="row"> @@ -301,11 +383,38 @@ <option value="de-DE">German (Deutsch)</option> <option value="ja-JP">Japanese (日本語)</option> </select> - <input id="languageValue" list="langList" placeholder="pick or type e.g. it-IT" hidden> - <datalist id="langList"> - <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="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"> - </datalist> + <!-- Native <select> (same reason as timezone above). --> + <select id="languageValue" hidden> + <option value="en-US">English (US) — en-US</option> + <option value="en-GB">English (UK) — en-GB</option> + <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 class="row"> @@ -321,24 +430,78 @@ <option value="middle_east">Middle East</option> <option value="australia">Australia</option> </select> - <div class="coords" id="locationCoords" hidden> - <input id="locationCity" list="cityList" placeholder="pick a city…" style="width:112px"> - <input id="locationLat" placeholder="lat"><input id="locationLon" placeholder="lon"> + <!-- Manual location = pick a city from a native select; its lat/lon + are looked up client-side (see CITIES below) and written to + 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> - <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 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> </div> </div> <script> const C = window.cfg; // sidebar navigation - const sections = ["general", "search", "naming", "performance", "privacy"]; + const sections = ["general", "search", "passwords", "naming", "performance", "privacy"]; function showSection(sec) { if (!sections.includes(sec)) return; 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). 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) => { for (const k of TOGGLES) { const el = document.getElementById(k); if (!el) continue; @@ -538,37 +702,126 @@ 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; }; - const tzV = val("timezoneValue"), lgV = val("languageValue"); - val("locationLat"); val("locationLon"); - bind("timezoneMode", null, (manual) => tzV.hidden = !manual); - // language: top-10 dropdown when spoofing, free-text locale when manual + // Value fields: <select> for tz + lang manual mode, <select> city for + // location manual mode. Each has an "Other…" sentinel at the end that + // reveals a text input so the user can enter a value not in the built-in + // list (any IANA zone, any BCP-47 locale). selectWithOther handles the + // 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 lngSpoof = document.getElementById("languageSpoof"); lngSpoof.value = s.languageSpoof || "en-US"; 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"; 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 locRegion = document.getElementById("locationRegion"); - const locCoords = document.getElementById("locationCoords"); + const locCity = document.getElementById("locationCity"); locRegion.value = s.locationRegion || "europe"; 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"; applyLoc(); locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); }); - // picking a city fills the exact lat/lon fields - 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] }; - document.getElementById("locationCity").addEventListener("change", (e) => { - const c = CITY[e.target.value.trim()]; if (!c) return; - const la = document.getElementById("locationLat"), lo = document.getElementById("locationLon"); - la.value = c[0]; lo.value = c[1]; C.set("locationLat", String(c[0])); C.set("locationLon", String(c[1])); + locCity.addEventListener("change", applyLoc); + // Prefill Other lat/lon inputs from saved settings. + locLatOther.value = s.locationLat ?? ""; + locLonOther.value = s.locationLon ?? ""; + const saveOtherCoords = () => { + 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 ---- function refreshCollisions() { C.collisionState().then((cs) => { @@ -588,6 +841,125 @@ document.getElementById("resetCollisions").onclick = () => { 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> </body>