fix(theseus/net): sec-ch-ua client hints look like stock Chrome (Brave-style)

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=<major>, 'Chromium';v=<major>, '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.
This commit is contained in:
Local Dev 2026-09-09 03:27:41 +02:00
parent 124325673f
commit 882de1654f
2 changed files with 245 additions and 2 deletions

245
main.js
View file

@ -712,6 +712,53 @@ function applyAcceptLanguage() {
session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`); session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`);
} catch {} } 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 ---- // ---- session restore + background throttling ----
const sessionFile = () => path.join(app.getPath("userData"), "session.json"); const sessionFile = () => path.join(app.getPath("userData"), "session.json");
function saveSession() { function saveSession() {
@ -1513,6 +1560,101 @@ function initAddons() {
return new Uint8Array(await wc.subtle.deriveBits( return new Uint8Array(await wc.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); { 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), approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
emitToPanel: (addonId, msg, payload) => { emitToPanel: (addonId, msg, payload) => {
if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return; 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). // the other storage clears (see before-quit hook).
const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault"); const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault");
let vaultState = null; // { key, purposeRoot, entries, _salt, _iters } 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 vaultOk = () => ({ ok: true });
const vaultErr = (m) => ({ ok: false, err: String(m) }); 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"); if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
const v = await loadVaultLib(); const v = await loadVaultLib();
vaultState = await v.unlockVault(vaultFile(), masterPassword); 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(); emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) }; return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); } } catch (e) { return vaultErr(e?.message || e); }
}); });
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 () => { ipcMain.handle("password-list", async () => {
if (!vaultState) return { ok: false, err: "locked" }; 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); } } 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) => { ipcMain.handle("clear-browsing-data", async (_e, opts) => {
const o = opts || {}; const o = opts || {};
await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage }); await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage });
@ -4478,6 +4720,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
applyPermissions(); applyPermissions();
applyEmbedCookieShim(); applyEmbedCookieShim();
applyAcceptLanguage(); applyAcceptLanguage();
applyClientHintsSpoof();
// Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView // Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView
// preloads (home/settings/popover/etc.), which stack on top of it. Must be // preloads (home/settings/popover/etc.), which stack on top of it. Must be
// called before any tab is created; whenReady runs before createWindow(). // called before any tab is created; whenReady runs before createWindow().

View file

@ -1,6 +1,6 @@
{ {
"name": "theseus-navigator", "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.", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode", "author": "Silent Mode",
"main": "main.js", "main": "main.js",