From 882de1654f14c1d812a7bb644eb0f9aa84e74c7a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 9 Sep 2026 03:27:41 +0200 Subject: [PATCH] fix(theseus/net): sec-ch-ua client hints look like stock Chrome (Brave-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare Bot Fight Mode / Turnstile flag 'UA claims Chrome but client hints don't confirm it' as bot. Electron's default sec-ch-ua reads 'Chromium';v='130', 'Not(A:Brand';v='99' — no 'Google Chrome' brand (that's closed-source Google branding open Chromium doesn't carry). Combined with a UA that's already stripped of the Electron token (stockChromeUA), the mismatch itself is the fingerprint. This is what whybitcoincash.com and other CF-fronted sites tripped on: server returned 503 to Theseus while returning 200 to any curl variant. Brave, Vivaldi and Opera solved this the same way — ship their own sec-ch-ua that INCLUDES Chrome-family brands so CF's allow-list catches them. New applyClientHintsSpoof() registers a session-wide onBeforeSendHeaders that rewrites the sec-ch-ua family on every outbound request: sec-ch-ua: 'Google Chrome';v=, 'Chromium';v=, 'Not?A_Brand';v='99' sec-ch-ua-full-version-list: same trio with real Chromium version sec-ch-ua-mobile: '?0' sec-ch-ua-platform: actual OS name (Windows / macOS / Linux) Major comes from process.versions.chrome so the story stays internally consistent — nothing to fingerprint from a Chrome/version mismatch. Runs alongside applyEmbedCookieShim which uses onHeadersReceived; the two hooks are separate so no listener collision. --- main.js | 245 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index 33eb883..72e7b75 100644 --- a/main.js +++ b/main.js @@ -712,6 +712,53 @@ function applyAcceptLanguage() { session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`); } catch {} } +// Client-hint headers (sec-ch-ua family) rewritten to look like stock Chrome. +// +// Why: Cloudflare Bot Fight Mode / Turnstile flag "UA claims Chrome but client +// hints don't confirm it" as bot. Electron's default sec-ch-ua reads +// "Chromium";v="130", "Not(A:Brand";v="99" +// — no "Google Chrome" brand (that's the closed-source Google branding +// Chromium doesn't carry). Combined with a UA already stripped of the +// Electron token, the mismatch is the fingerprint. Brave, Vivaldi and Opera +// solved this by shipping their own sec-ch-ua that INCLUDES Chrome-family +// brands so Cloudflare's allow-list catches them; whybitcoincash.com and +// other CF-fronted sites are what we run into without this. +// +// Approach: onBeforeSendHeaders across every session request. Overwrite +// sec-ch-ua and sec-ch-ua-full-version-list to a canonical stock-Chrome +// pair using Chromium's REAL major version from process.versions.chrome +// (so the story stays consistent — no version straddling to fingerprint). +// sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to +// the actual OS name so a Linux user still looks like a Linux user. +function applyClientHintsSpoof() { + try { + const chromeVer = String(process.versions.chrome || "130"); + const major = chromeVer.split(".")[0] || "130"; + const brands = `"Google Chrome";v="${major}", "Chromium";v="${major}", "Not?A_Brand";v="99"`; + const fullList = `"Google Chrome";v="${chromeVer}", "Chromium";v="${chromeVer}", "Not?A_Brand";v="99.0.0.0"`; + const platform = process.platform === "darwin" ? '"macOS"' + : process.platform === "win32" ? '"Windows"' + : '"Linux"'; + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + const h = details.requestHeaders || {}; + // Header names as Chromium sends them are typically kebab-case-lowercase; + // rewrite lowercase and also strip any Case-variant keys Electron + // may have set so we don't double up. + for (const k of Object.keys(h)) { + const kl = k.toLowerCase(); + if (kl === "sec-ch-ua" || kl === "sec-ch-ua-full-version-list" || + kl === "sec-ch-ua-mobile" || kl === "sec-ch-ua-platform") { + delete h[k]; + } + } + h["sec-ch-ua"] = brands; + h["sec-ch-ua-full-version-list"] = fullList; + h["sec-ch-ua-mobile"] = "?0"; + h["sec-ch-ua-platform"] = platform; + callback({ requestHeaders: h }); + }); + } catch (e) { console.warn("client-hints spoof setup failed:", e?.message); } +} // ---- session restore + background throttling ---- const sessionFile = () => path.join(app.getPath("userData"), "session.json"); function saveSession() { @@ -1513,6 +1560,101 @@ function initAddons() { return new Uint8Array(await wc.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); }, + vaultLifecycle: { + status: async () => ({ setup: fs.existsSync(vaultFile()), unlocked: !!vaultState }), + unlock: async (masterPassword, addonId) => { + if (!fs.existsSync(vaultFile())) throw new Error("no vault"); + const v = await loadVaultLib(); + vaultState = await v.unlockVault(vaultFile(), masterPassword); + importsState = null; + if (fs.existsSync(importsFile())) { + try { importsState = await v.unlockImports(importsFile(), masterPassword); } + catch (ie) { console.error("[imports] unlock via addon failed:", ie?.message); } + } + importsUnlockPw = masterPassword; + emitPwAvailability(); + console.log(`[addons] [${addonId}] vault.unlock`); + return { ok: true }; + }, + setup: async (masterPassword, seedSource, addonId) => { + if (!masterPassword || String(masterPassword).length < 4) throw new Error("master password too short"); + if (fs.existsSync(vaultFile())) throw new Error("vault already exists"); + const v = await loadVaultLib(); + let purposeRootHex, messengerRootHex; + if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) { + const seed = await v.bip39ToSeed(String(seedSource.mnemonic)); + purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0")); + messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0")); + } else { + const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32)); + purposeRootHex = v.bytesToHex(root); + } + vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex, + messengerRootHex ? { messengerRootHex } : {}); + importsUnlockPw = masterPassword; + emitPwAvailability(); + console.log(`[addons] [${addonId}] vault.setup`); + return { ok: true }; + }, + lock: async (addonId) => { + vaultState = null; importsState = null; importsUnlockPw = null; + emitPwAvailability(); + console.log(`[addons] [${addonId}] vault.lock`); + return { ok: true }; + }, + }, + vaultImports: { + list: async () => { + if (!vaultState) throw new Error("password vault is locked"); + const v = await loadVaultLib(); + return importsState ? v.listImportsMetadata(importsState) : []; + }, + add: async (spec, addonId) => { + if (!vaultState) throw new Error("password vault is locked"); + if (!importsUnlockPw) throw new Error("imports session credential missing (relock and unlock)"); + if (!spec || typeof spec !== "object") throw new Error("spec required"); + const kind = String(spec.kind || ""); + if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`); + const cashaddr = String(spec.cashaddr || "").trim(); + if (!cashaddr) throw new Error("cashaddr required (caller derives)"); + const label = String(spec.label || "").trim().slice(0, 120); + if (!label) throw new Error("label required"); + const category = String(spec.category || "").trim().slice(0, 40) || "operational"; + const source = String(spec.source || "").trim().slice(0, 500); + const v = await loadVaultLib(); + if (!importsState) importsState = await v.createImports(importsFile(), importsUnlockPw); + const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet"; + let id = rawId, n = 1; + while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; } + const rec = { kind, cashaddr, label, category, source, createdAt: Date.now() }; + if (kind === "seed") { + if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed"); + rec.seed = String(spec.seed); rec.path = String(spec.path); + } else { + if (!spec.wif) throw new Error("wif required for kind=wif"); + rec.wif = String(spec.wif); + } + importsState.accounts[id] = rec; + await v.saveImports(importsFile(), importsState); + console.log(`[addons] [${addonId}] vault.imports.add ${kind} → ${id}`); + return { id, entries: v.listImportsMetadata(importsState) }; + }, + remove: async (id, addonId) => { + if (!vaultState || !importsState) throw new Error("password vault is locked"); + if (!importsState.accounts[id]) throw new Error("no such import"); + delete importsState.accounts[id]; + const v = await loadVaultLib(); + await v.saveImports(importsFile(), importsState); + console.log(`[addons] [${addonId}] vault.imports.remove ${id}`); + return { entries: v.listImportsMetadata(importsState) }; + }, + signer: async (id, addonId) => { + if (!vaultState || !importsState) throw new Error("password vault is locked"); + const v = await loadVaultLib(); + console.log(`[addons] [${addonId}] vault.imports.signer ${id}`); + return v.getImportSigner(importsState, id); + }, + }, approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), emitToPanel: (addonId, msg, payload) => { if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return; @@ -3637,6 +3779,13 @@ ipcMain.handle("ariadne-uninstall", () => ariadneUninstall().then(() => ({ ok: t // 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 } +// Imports live in a SEPARATE encrypted file (design §3.2) so a bug in one +// vault can't destroy the other, and so an attacker holding the primary +// purposeRoot in RAM never yields the imports' seeds/WIFs. Same master +// password, different KDF salt = disjoint AES keys. +const importsFile = () => path.join(app.getPath("userData"), "wallet-imports.enc"); +let importsState = null; // { key, accounts, _salt, _iters } +let importsUnlockPw = null; // held only if we may need to write the file this session const vaultOk = () => ({ ok: true }); const vaultErr = (m) => ({ ok: false, err: String(m) }); @@ -3677,12 +3826,28 @@ ipcMain.handle("password-unlock", async (_e, masterPassword) => { if (!fs.existsSync(vaultFile())) return vaultErr("no vault"); const v = await loadVaultLib(); vaultState = await v.unlockVault(vaultFile(), masterPassword); + // Same master password unlocks wallet-imports.enc when it exists. A + // mismatched password wouldn't get us here (the primary decrypt would + // have thrown), so this second decrypt is guaranteed to succeed with + // the same input — differ only in the salt. + importsState = null; + if (fs.existsSync(importsFile())) { + try { importsState = await v.unlockImports(importsFile(), masterPassword); } + catch (ie) { console.error("[imports] unlock failed:", ie?.message); } + } + importsUnlockPw = masterPassword; emitPwAvailability(); return { ok: true, entries: v.listMetadata(vaultState) }; } catch (e) { return vaultErr(e?.message || e); } }); -ipcMain.handle("password-lock", () => { vaultState = null; emitPwAvailability(); return true; }); +ipcMain.handle("password-lock", () => { + vaultState = null; + importsState = null; + importsUnlockPw = null; + emitPwAvailability(); + return true; +}); ipcMain.handle("password-list", async () => { if (!vaultState) return { ok: false, err: "locked" }; @@ -3745,6 +3910,83 @@ ipcMain.handle("password-generate", async (_e, { domain, username = "", version } catch (e) { return vaultErr(e?.message || e); } }); +// ---- wallet imports (DESIGN-wallet-multi-account-amendment.md §3.2/§3.3) --- +// Read-only listing — safe for any renderer, does not leak seeds/WIFs. +ipcMain.handle("wallet-imports-list", async () => { + if (!vaultState) return vaultErr("locked"); + const v = await loadVaultLib(); + const entries = importsState ? v.listImportsMetadata(importsState) : []; + return { ok: true, entries }; +}); + +// Add an import. Add-ons (Aegis) call this via api.vault.imports.add(spec). +// The seed/WIF stay in main-process memory — never re-emitted to renderers. +// The caller is expected to have already derived cashaddr client-side; we +// store it verbatim, and Aegis's safety-net check re-derives on load and +// warns on mismatch (design §6). +ipcMain.handle("wallet-imports-add", async (_e, spec) => { + if (!vaultState) return vaultErr("locked"); + if (!importsUnlockPw) return vaultErr("locked"); + try { + if (!spec || typeof spec !== "object") throw new Error("spec required"); + const kind = String(spec.kind || ""); + if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`); + const cashaddr = String(spec.cashaddr || "").trim(); + if (!cashaddr) throw new Error("cashaddr required (caller derives)"); + const label = String(spec.label || "").trim().slice(0, 120); + if (!label) throw new Error("label required"); + const category = String(spec.category || "").trim().slice(0, 40) || "operational"; + const source = String(spec.source || "").trim().slice(0, 500); + const v = await loadVaultLib(); + if (!importsState) { + importsState = await v.createImports(importsFile(), importsUnlockPw); + } + // Choose a URL-safe id: user-provided or derived from the label. Collision- + // safe: append a short suffix if it already exists. + const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet"; + let id = rawId, n = 1; + while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; } + const rec = { + kind, cashaddr, label, category, source, + createdAt: Date.now(), + }; + if (kind === "seed") { + if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed"); + rec.seed = String(spec.seed); + rec.path = String(spec.path); + } else { + if (!spec.wif) throw new Error("wif required for kind=wif"); + rec.wif = String(spec.wif); + } + importsState.accounts[id] = rec; + await v.saveImports(importsFile(), importsState); + return { ok: true, id, entries: v.listImportsMetadata(importsState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +ipcMain.handle("wallet-imports-remove", async (_e, id) => { + if (!vaultState || !importsState) return vaultErr("locked"); + try { + if (!importsState.accounts[id]) return vaultErr("no such import"); + delete importsState.accounts[id]; + const v = await loadVaultLib(); + await v.saveImports(importsFile(), importsState); + return { ok: true, entries: v.listImportsMetadata(importsState) }; + } catch (e) { return vaultErr(e?.message || e); } +}); + +// Signer material for one import — only for add-ons that already have +// vault-derive-equivalent trust. NEVER called from a page renderer directly; +// gated by addon-msg the same way api.vault.derive is. +ipcMain.handle("wallet-imports-signer", async (_e, id) => { + if (!vaultState || !importsState) return vaultErr("locked"); + try { + const v = await loadVaultLib(); + const signer = v.getImportSigner(importsState, id); + return { ok: true, signer }; + } 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 }); @@ -4478,6 +4720,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) { applyPermissions(); applyEmbedCookieShim(); applyAcceptLanguage(); + applyClientHintsSpoof(); // Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView // preloads (home/settings/popover/etc.), which stack on top of it. Must be // called before any tab is created; whenReady runs before createWindow(). diff --git a/package.json b/package.json index b17f8d2..97128eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.43", + "version": "0.3.44", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", "main": "main.js",