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:
parent
124325673f
commit
882de1654f
2 changed files with 245 additions and 2 deletions
245
main.js
245
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().
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue