From 660b6bf58c1a348e9f1b6ed7f219c3d740c97421 Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Sun, 21 Jun 2026 01:10:12 +0200 Subject: [PATCH] feat(security): verify binaries with pinned Ed25519 signature on SHA256SUMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the same-origin checksum with a detached Ed25519 signature (SHA256SUMS.sig) verified against a pinned public key before extraction, closing #308: a compromised download mirror can no longer certify a tampered binary. The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory and non-bypassable on the official download path; custom CLOAKBROWSER_DOWNLOAD_URL mirrors keep the legacy skippable checksum. Silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected — the version= line is ignored by their checksum parser. Python uses cryptography; JS uses node:crypto. Adds tamper, downgrade, and fail-closed tests in both languages. --- .dockerignore | 5 + CHANGELOG.md | 1 + README.md | 6 +- cloakbrowser/config.py | 13 ++ cloakbrowser/download.py | 181 +++++++++++++++++++++++++--- js/src/config.ts | 13 ++ js/src/download.ts | 180 ++++++++++++++++++++++++++-- js/tests/signature.test.ts | 234 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_update.py | 239 ++++++++++++++++++++++++++++++++++++- 10 files changed, 839 insertions(+), 34 deletions(-) create mode 100644 .dockerignore create mode 100644 js/tests/signature.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9460091 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +# Never let signing material enter a Docker build context / image. +# The test image (test-infra/Dockerfile.test) uses selective COPY today, but +# this is defense-in-depth against a future `COPY . .`. +test-infra/signing/ +*.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d0bb5..ba4c7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi ## [Unreleased] +- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected. - **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before. - **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument. diff --git a/README.md b/README.md index ccfed09..b181eef 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ The binary includes 58 source-level patches covering canvas, WebGL, audio, fonts These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. -Binary downloads are verified with SHA-256 checksums to ensure integrity. +Binary downloads are verified against a pinned Ed25519 signature on the published checksums before extraction, so the download is confirmed authentic (genuinely ours) and not just intact. A compromised mirror cannot serve a tampered or downgraded binary. ## API @@ -621,7 +621,7 @@ Access the original un-patched Playwright page at `page._original` if you need r | `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | | `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary | | `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks | -| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download | +| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Only applies to a custom `CLOAKBROWSER_DOWNLOAD_URL`: set to `true` to skip its checksum check. Signature verification on the official download path is mandatory and cannot be skipped. | | `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it | | `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) | | `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts | @@ -1288,7 +1288,7 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@ ## Security -All releases are signed for supply chain verification. +The wrapper automatically verifies every binary download against a pinned Ed25519 signature on the published checksums before extraction — a compromised mirror cannot serve a tampered or downgraded binary. Releases are additionally signed for manual supply chain verification: ```bash # Verify GPG signature (binary release tag) diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index c52448c..a8ee943 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -25,6 +25,19 @@ PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { "windows-x64": "146.0.7680.177.5", } +# --------------------------------------------------------------------------- +# Ed25519 public keys for verifying downloaded binaries. +# +# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig. +# The wrapper verifies that signature against the keys below before trusting +# any hash in the manifest, so the download origin alone cannot certify a +# tampered binary. Values are base64 of the 32-byte raw public key. Multiple +# entries are accepted to allow key rotation. +# --------------------------------------------------------------------------- +BINARY_SIGNING_PUBKEYS: list[str] = [ + "MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=", +] + # --------------------------------------------------------------------------- # Playwright default args to suppress — these leak automation signals. # --enable-automation: exposes navigator.webdriver = true diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py index fc064eb..725c046 100644 --- a/cloakbrowser/download.py +++ b/cloakbrowser/download.py @@ -23,6 +23,7 @@ import httpx from ._version import __version__ as _wrapper_version from .config import ( + BINARY_SIGNING_PUBKEYS, CHROMIUM_VERSION, DOWNLOAD_BASE_URL, GITHUB_API_URL, @@ -162,9 +163,11 @@ def _download_and_extract(version: str | None = None) -> None: ) _download_file(fallback_url, tmp_path) - # Verify checksum before extraction - if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true": - _verify_download_checksum(tmp_path, version) + # Verify the download before extraction. On the official path this is a + # mandatory, non-bypassable Ed25519 signature check (see + # _verify_download_checksum); the skip flag only applies to custom + # self-hosted CLOAKBROWSER_DOWNLOAD_URL setups. + _verify_download_checksum(tmp_path, version) _extract_archive(tmp_path, binary_dir, binary_path) _show_welcome() @@ -174,22 +177,159 @@ def _download_and_extract(version: str | None = None) -> None: def _verify_download_checksum(file_path: Path, version: str | None = None) -> None: - """Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch.""" - checksums = _fetch_checksums(version) + """Verify the downloaded archive's integrity and authenticity. + + Official path (cloakbrowser.dev / GitHub Releases): fetch SHA256SUMS plus + its detached Ed25519 signature SHA256SUMS.sig, verify the signature against + the pinned public keys FIRST, then verify the archive's SHA-256 against the + now-authenticated manifest. Mandatory and non-bypassable — a same-origin + manifest can no longer certify a tampered binary (#308). + + Custom self-hosted path (CLOAKBROWSER_DOWNLOAD_URL set): the pinned keys do + not apply to a third-party server, so fall back to the plain same-origin + SHA256SUMS check, which CLOAKBROWSER_SKIP_CHECKSUM may bypass. + """ tarball_name = get_archive_name() - if checksums is None: - logger.warning("SHA256SUMS not available for this release — skipping checksum verification") + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + # Self-hosted mirror: signature scheme does not apply. Preserve the + # legacy same-origin checksum behavior, skippable as before. + if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() == "true": + logger.warning( + "CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL" + ) + return + checksums = _fetch_checksums(version) + if checksums is None: + logger.warning( + "SHA256SUMS not available from custom URL — skipping checksum verification" + ) + return + expected = checksums.get(tarball_name) + if expected is None: + logger.warning( + "SHA256SUMS found but no entry for %s — skipping verification", tarball_name + ) + return + _verify_checksum(file_path, expected) return + # Official path: signature is the trust root and is non-bypassable. + manifest = _fetch_signed_manifest(version) + if manifest is None: + raise RuntimeError( + "Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) " + "for this release — refusing to use an unverified binary. " + "Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues" + ) + manifest_bytes, sig_bytes = manifest + _verify_signature(manifest_bytes, sig_bytes) + manifest_text = manifest_bytes.decode("utf-8") + + # Version binding: the signed manifest must declare the version we asked for. + # The signature proves "we made this manifest", not "this is the version you + # requested" — without this check a mirror could serve a genuinely-signed + # older release in place of the requested one (forced downgrade). + requested = version or get_chromium_version() + declared = _parse_manifest_version(manifest_text) + if declared != requested: + raise RuntimeError( + f"Version mismatch in signed SHA256SUMS: requested {requested}, " + f"manifest declares {declared or 'none'}. Refusing (possible downgrade)." + ) + + checksums = _parse_checksums(manifest_text) expected = checksums.get(tarball_name) if expected is None: - logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name) - return - + raise RuntimeError( + f"Signature-verified SHA256SUMS has no entry for {tarball_name} — " + f"cannot confirm binary integrity." + ) _verify_checksum(file_path, expected) +def _parse_manifest_version(text: str) -> str | None: + """Read the 'version=' line from a signed manifest. None if absent. + + The line has no internal whitespace so older wrappers' SHA256SUMS parsers + ignore it (they only accept ' ' lines). + """ + for line in text.splitlines(): + line = line.strip() + if line.startswith("version="): + return line[len("version="):].strip() + return None + + +def _fetch_signed_manifest(version: str | None = None) -> tuple[bytes, bytes] | None: + """Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or None. + + Both files are fetched from the SAME origin so the signature always matches + the exact manifest bytes it certifies. The primary origin is tried first, + then the GitHub Releases mirror. follow_redirects mirrors _fetch_checksums: + cloakbrowser.dev 301-redirects /chromium-v* to GitHub Releases. + """ + v = version or get_chromium_version() + bases = [ + f"{DOWNLOAD_BASE_URL}/chromium-v{v}", + f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}", + ] + for base in bases: + try: + manifest_resp = httpx.get( + f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0 + ) + manifest_resp.raise_for_status() + sig_resp = httpx.get( + f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0 + ) + sig_resp.raise_for_status() + return manifest_resp.content, sig_resp.content + except Exception: + continue + return None + + +def _verify_signature(manifest_bytes: bytes, sig_b64: bytes) -> None: + """Verify a detached Ed25519 signature over the raw manifest bytes. + + sig_b64 is the base64 of the 64-byte raw signature. Tries each pinned key + in BINARY_SIGNING_PUBKEYS; succeeds if any validates. Raises RuntimeError + if the signature is malformed or no pinned key validates it. + """ + import base64 + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + try: + signature = base64.b64decode(sig_b64.strip(), validate=True) + except Exception as exc: + raise RuntimeError(f"Malformed SHA256SUMS.sig (not valid base64): {exc}") + + for pubkey_b64 in BINARY_SIGNING_PUBKEYS: + try: + pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64)) + except Exception: + # Skip an unparseable pinned key (e.g. the placeholder) rather than + # aborting — another pinned key may still validate. + continue + try: + pub.verify(signature, manifest_bytes) + logger.info("SHA256SUMS signature verified: Ed25519 OK") + return + except Exception: + # InvalidSignature, or a malformed/wrong-length signature that makes + # verify raise something else — either way this key didn't match, + # so try the next pinned key (and ultimately fail closed below). + continue + + raise RuntimeError( + "SHA256SUMS signature verification failed — no pinned key validated the " + "manifest. The binary's authenticity could not be confirmed. " + "Report at https://github.com/CloakHQ/cloakbrowser/issues" + ) + + def _fetch_checksums(version: str | None = None) -> dict[str, str] | None: """Fetch SHA256SUMS file for a version. Returns {filename: hash} or None.""" v = version or get_chromium_version() @@ -211,17 +351,22 @@ def _fetch_checksums(version: str | None = None) -> dict[str, str] | None: def _parse_checksums(text: str) -> dict[str, str]: - """Parse SHA256SUMS format: 'hash filename' per line.""" + """Parse SHA256SUMS format: '<64-hex sha256> filename' per line. + + Only lines whose first token is a 64-character hex digest are accepted + (matches the JS parser); blank lines, the version= line, and any other + junk are ignored. + """ result = {} for line in text.strip().splitlines(): - line = line.strip() - if not line: + parts = line.strip().split(None, 1) + if len(parts) != 2: continue - parts = line.split(None, 1) - if len(parts) == 2: - hash_val, filename = parts - filename = filename.lstrip("*") - result[filename] = hash_val.lower() + hash_val, filename = parts + hash_val = hash_val.lower() + if len(hash_val) != 64 or any(c not in "0123456789abcdef" for c in hash_val): + continue + result[filename.lstrip("*")] = hash_val return result diff --git a/js/src/config.ts b/js/src/config.ts index 9454937..991b99f 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -37,6 +37,19 @@ export const PLATFORM_CHROMIUM_VERSIONS: Record = { "windows-x64": "146.0.7680.177.5", }; +// --------------------------------------------------------------------------- +// Ed25519 public keys for verifying downloaded binaries. +// +// Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig. +// The wrapper verifies that signature against the keys below before trusting +// any hash in the manifest, so the download origin alone cannot certify a +// tampered binary. Values are base64 of the 32-byte raw public key. Multiple +// entries are accepted to allow key rotation. Keep in parity with config.py. +// --------------------------------------------------------------------------- +export const BINARY_SIGNING_PUBKEYS: string[] = [ + "MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=", +]; + // --------------------------------------------------------------------------- // Platform detection // --------------------------------------------------------------------------- diff --git a/js/src/download.ts b/js/src/download.ts index 3f9a766..b28e237 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -5,7 +5,7 @@ */ import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pipeline } from "node:stream/promises"; @@ -14,6 +14,7 @@ import { extract as tarExtract } from "tar"; import type { BinaryInfo } from "./types.js"; import { + BINARY_SIGNING_PUBKEYS, DOWNLOAD_BASE_URL, GITHUB_API_URL, GITHUB_DOWNLOAD_BASE_URL, @@ -195,10 +196,11 @@ async function downloadAndExtract(version?: string): Promise { await downloadFile(fallbackUrl, tmpPath); } - // Verify checksum before extraction - if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") { - await verifyDownloadChecksum(tmpPath, version); - } + // Verify the download before extraction. On the official path this is a + // mandatory, non-bypassable Ed25519 signature check (see + // verifyDownloadChecksum); the skip flag only applies to custom + // self-hosted CLOAKBROWSER_DOWNLOAD_URL setups. + await verifyDownloadChecksum(tmpPath, version); await extractArchive(tmpPath, binaryDir, binaryPath); showWelcome(); @@ -210,22 +212,176 @@ async function downloadAndExtract(version?: string): Promise { } } -async function verifyDownloadChecksum(filePath: string, version?: string): Promise { - const checksums = await fetchChecksums(version); +/** @internal Exported for testing only. */ +export async function verifyDownloadChecksum(filePath: string, version?: string): Promise { const tarballName = getArchiveName(); - if (!checksums) { - console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification"); + if (process.env.CLOAKBROWSER_DOWNLOAD_URL) { + // Self-hosted mirror: the pinned signature keys do not apply to a + // third-party server. Preserve the legacy same-origin checksum behavior, + // skippable via CLOAKBROWSER_SKIP_CHECKSUM. + if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() === "true") { + console.warn( + "[cloakbrowser] CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL" + ); + return; + } + const checksums = await fetchChecksums(version); + if (!checksums) { + console.warn( + "[cloakbrowser] SHA256SUMS not available from custom URL — skipping checksum verification" + ); + return; + } + const expectedCustom = checksums.get(tarballName); + if (!expectedCustom) { + console.warn( + `[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification` + ); + return; + } + await verifyChecksum(filePath, expectedCustom); return; } + // Official path: signature is the trust root and is non-bypassable. + const manifest = await fetchSignedManifest(version); + if (!manifest) { + throw new Error( + "Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) for " + + "this release — refusing to use an unverified binary. " + + "Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues" + ); + } + const { manifestBytes, sigBytes } = manifest; + verifySignature(manifestBytes, sigBytes); + const manifestText = new TextDecoder().decode(manifestBytes); + + // Version binding: the signed manifest must declare the version we asked for. + // The signature proves "we made this manifest", not "this is the version you + // requested" — without this check a mirror could serve a genuinely-signed + // older release in place of the requested one (forced downgrade). + const requested = version || getChromiumVersion(); + const declared = parseManifestVersion(manifestText); + if (declared !== requested) { + throw new Error( + `Version mismatch in signed SHA256SUMS: requested ${requested}, ` + + `manifest declares ${declared ?? "none"}. Refusing (possible downgrade).` + ); + } + + const checksums = parseChecksums(manifestText); const expected = checksums.get(tarballName); if (!expected) { - console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`); - return; + throw new Error( + `Signature-verified SHA256SUMS has no entry for ${tarballName} — ` + + `cannot confirm binary integrity.` + ); + } + await verifyChecksum(filePath, expected); +} + +/** + * Read the 'version=' line from a signed manifest. null if absent. + * The line has no internal whitespace so older wrappers' SHA256SUMS parsers + * ignore it (they only accept ' ' lines). + * @internal Exported for testing only. + */ +export function parseManifestVersion(text: string): string | null { + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line.startsWith("version=")) { + return line.slice("version=".length).trim(); + } + } + return null; +} + +/** + * Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or null. + * Both files come from the SAME origin so the signature always matches the + * exact manifest bytes it certifies. Primary origin first, then GitHub mirror. + * @internal Exported for testing only. + */ +export async function fetchSignedManifest( + version?: string +): Promise<{ manifestBytes: Uint8Array; sigBytes: Uint8Array } | null> { + const v = version || getChromiumVersion(); + const bases = [ + `${DOWNLOAD_BASE_URL}/chromium-v${v}`, + `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}`, + ]; + for (const base of bases) { + try { + const manifestResp = await fetch(`${base}/SHA256SUMS`, { + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + if (!manifestResp.ok) continue; + const sigResp = await fetch(`${base}/SHA256SUMS.sig`, { + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + if (!sigResp.ok) continue; + return { + manifestBytes: new Uint8Array(await manifestResp.arrayBuffer()), + sigBytes: new Uint8Array(await sigResp.arrayBuffer()), + }; + } catch { + continue; + } + } + return null; +} + +/** + * Verify a detached Ed25519 signature over the raw manifest bytes. + * sigB64Bytes is the (base64-text) content of SHA256SUMS.sig. Tries each pinned + * key; succeeds if any validates. Throws if malformed or no key validates. + * @internal Exported for testing only. + */ +export function verifySignature(manifestBytes: Uint8Array, sigB64Bytes: Uint8Array): void { + // Node's Buffer.from(...,"base64") is lenient — it silently drops invalid + // characters instead of throwing. Validate by canonical round-trip so a + // malformed .sig is reported as such (parity with Python's + // base64.b64decode(validate=True)). + const sigText = new TextDecoder().decode(sigB64Bytes).trim(); + const signature = Buffer.from(sigText, "base64"); + if (signature.toString("base64") !== sigText) { + throw new Error("Malformed SHA256SUMS.sig (not valid base64)"); } - await verifyChecksum(filePath, expected); + for (const pubkeyB64 of BINARY_SIGNING_PUBKEYS) { + let keyObject; + try { + // Build an Ed25519 public key from raw 32 bytes via JWK import. + const x = Buffer.from(pubkeyB64, "base64").toString("base64url"); + keyObject = createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x }, + format: "jwk", + }); + } catch { + // Skip an unparseable pinned key (e.g. the placeholder); another may validate. + continue; + } + try { + if (cryptoVerify(null, manifestBytes, keyObject, signature)) { + console.log("[cloakbrowser] SHA256SUMS signature verified: Ed25519 OK"); + return; + } + } catch { + // A malformed/wrong-length signature can make verify throw rather than + // return false — treat it as a non-match and try the next pinned key + // (parity with Python's try/except around pub.verify), failing closed below. + continue; + } + } + + throw new Error( + "SHA256SUMS signature verification failed — no pinned key validated the " + + "manifest. The binary's authenticity could not be confirmed. " + + "Report at https://github.com/CloakHQ/cloakbrowser/issues" + ); } /** @internal Exported for testing only. */ diff --git a/js/tests/signature.test.ts b/js/tests/signature.test.ts new file mode 100644 index 0000000..169eaf0 --- /dev/null +++ b/js/tests/signature.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { sign as cryptoSign, createPrivateKey, createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Generate a throwaway signing keypair BEFORE the config mock is hoisted, then +// pin its public key so verifySignature accepts signatures we produce here. +const h = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require("node:crypto"); + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const otherPub = crypto.generateKeyPairSync("ed25519").publicKey; + const rawB64 = (pk: any) => + Buffer.from(pk.export({ format: "jwk" }).x, "base64url").toString("base64"); + return { + pinnedPubB64: rawB64(publicKey), + otherPubB64: rawB64(otherPub), + privPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + }; +}); + +vi.mock("../src/config.js", async (importActual) => { + const actual = await importActual(); + return { ...actual, BINARY_SIGNING_PUBKEYS: [h.pinnedPubB64] }; +}); + +import { + fetchSignedManifest, + parseChecksums, + parseManifestVersion, + verifyDownloadChecksum, + verifySignature, +} from "../src/download.js"; +import { getArchiveName, getChromiumVersion } from "../src/config.js"; + +/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */ +function sign(manifest: Uint8Array): Uint8Array { + const priv = createPrivateKey(h.privPem); + const sig = cryptoSign(null, manifest, priv); // raw 64-byte Ed25519 signature + return new TextEncoder().encode(sig.toString("base64")); +} + +const enc = (s: string) => new TextEncoder().encode(s); + +describe("verifySignature", () => { + it("accepts a valid signature", () => { + const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n"); + expect(() => verifySignature(manifest, sign(manifest))).not.toThrow(); + }); + + it("rejects a tampered manifest", () => { + const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n"); + const sig = sign(manifest); + const tampered = enc("xyz cloakbrowser-linux-x64.tar.gz\n"); + expect(() => verifySignature(tampered, sig)).toThrow(/signature verification failed/); + }); + + it("rejects malformed base64 in the .sig", () => { + expect(() => verifySignature(enc("data\n"), enc("!!!not base64!!!"))) + .toThrow(/Malformed/); + }); + + it("rejects a signature from a non-pinned key", async () => { + // Re-mock config so ONLY the other key is pinned, then the signature + // (made with the real key) must fail. + vi.resetModules(); + vi.doMock("../src/config.js", async (importActual) => { + const actual = await importActual(); + return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64] }; + }); + const { verifySignature: vs } = await import("../src/download.js"); + const manifest = enc("data\n"); + expect(() => vs(manifest, sign(manifest))).toThrow(/signature verification failed/); + vi.doUnmock("../src/config.js"); + vi.resetModules(); + }); + + it("accepts a signature under the new key during rotation", async () => { + // Pin BOTH keys (old + new) and sign with the real (new) key — must pass. + vi.resetModules(); + vi.doMock("../src/config.js", async (importActual) => { + const actual = await importActual(); + return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64, h.pinnedPubB64] }; + }); + const { verifySignature: vs } = await import("../src/download.js"); + const manifest = enc("rotated\n"); + expect(() => vs(manifest, sign(manifest))).not.toThrow(); + vi.doUnmock("../src/config.js"); + vi.resetModules(); + }); +}); + +describe("verifyDownloadChecksum (official path, fail-closed)", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.CLOAKBROWSER_DOWNLOAD_URL; + delete process.env.CLOAKBROWSER_SKIP_CHECKSUM; + }); + + function tmpFile(bytes: Buffer): string { + const p = path.join(os.tmpdir(), `cloak-sig-${process.pid}-${bytes.length}-${bytes[0]}`); + fs.writeFileSync(p, bytes); + return p; + } + + /** Mock fetch to serve a signed manifest for the official URLs. */ + function mockManifest(manifestBytes: Uint8Array) { + const sig = sign(manifestBytes); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const body = url.endsWith(".sig") ? sig : manifestBytes; + return { ok: true, arrayBuffer: async () => body.buffer } as Response; + }); + } + + /** Manifest body with the bound version line prepended (defaults to current). */ + const body = (lines: string, version = getChromiumVersion()) => + enc(`version=${version}\n${lines}`); + + it("passes when signature is valid and hash matches", async () => { + const data = Buffer.from("the real binary"); + const file = tmpFile(data); + const hash = createHash("sha256").update(data).digest("hex"); + mockManifest(body(`${hash} ${getArchiveName()}\n`)); + await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined(); + }); + + it("fails when the binary is tampered (hash mismatch)", async () => { + const file = tmpFile(Buffer.from("a malicious binary")); + const goodHash = createHash("sha256").update(Buffer.from("the real binary")).digest("hex"); + mockManifest(body(`${goodHash} ${getArchiveName()}\n`)); + await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Checksum verification failed/); + }); + + it("fails on a signed manifest for the wrong version (downgrade)", async () => { + const data = Buffer.from("the real binary"); + const file = tmpFile(data); + const hash = createHash("sha256").update(data).digest("hex"); + // Genuinely signed, but declares an old version we did not request. + mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0")); + await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/); + }); + + it("fails when the version line is missing (binding required)", async () => { + const data = Buffer.from("the real binary"); + const file = tmpFile(data); + const hash = createHash("sha256").update(data).digest("hex"); + mockManifest(enc(`${hash} ${getArchiveName()}\n`)); // no version= line + await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/); + }); + + it("fails closed when no signed manifest can be fetched", async () => { + const file = tmpFile(Buffer.from("x")); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response); + await expect(verifyDownloadChecksum(file)).rejects.toThrow(/signed SHA256SUMS/); + }); + + it("fails when the signed manifest has no entry for this platform", async () => { + const file = tmpFile(Buffer.from("x")); + const someHash = "0".repeat(64); + mockManifest(body(`${someHash} some-other-file.tar.gz\n`)); + await expect(verifyDownloadChecksum(file)).rejects.toThrow(/no entry for/); + }); + + it("custom download URL keeps the legacy skippable path (no signature fetch)", async () => { + const file = tmpFile(Buffer.from("x")); + process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://my-mirror.test"; + process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true"; + const spy = vi.spyOn(globalThis, "fetch"); + await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe("version binding", () => { + it("reads the version= line", () => { + expect( + parseManifestVersion("version=146.0.7680.177.5\nabc file.tar.gz\n") + ).toBe("146.0.7680.177.5"); + }); + + it("returns null when absent", () => { + expect(parseManifestVersion("abc file.tar.gz\n")).toBeNull(); + }); + + it("old parseChecksums ignores the version line", () => { + const result = parseChecksums( + `version=146.0.7680.177.5\n${"a".repeat(64)} cloakbrowser-linux-x64.tar.gz\n` + ); + expect(result.size).toBe(1); + expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true); + }); +}); + +describe("fetchSignedManifest", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const mockPair = (manifest: string, sig: string, failPrimarySig = false) => + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const isSig = url.endsWith(".sig"); + if (url.includes("cloakbrowser.dev") && isSig && failPrimarySig) { + return { ok: false, status: 404 } as Response; + } + return { + ok: true, + arrayBuffer: async () => + new TextEncoder().encode(isSig ? sig : manifest).buffer, + } as Response; + }); + + it("returns manifest + sig from the primary origin", async () => { + mockPair("MANIFEST", "U0lH"); + const result = await fetchSignedManifest("1.2.3.4"); + expect(new TextDecoder().decode(result!.manifestBytes)).toBe("MANIFEST"); + expect(new TextDecoder().decode(result!.sigBytes)).toBe("U0lH"); + }); + + it("falls back to GitHub when the primary .sig is missing", async () => { + const spy = mockPair("MANIFEST", "U0lH", true); + const result = await fetchSignedManifest("1.2.3.4"); + expect(result).not.toBeNull(); + // primary SHA256SUMS + primary .sig (404) + github SHA256SUMS + github .sig + expect(spy.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + it("returns null when everything fails", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network")); + expect(await fetchSignedManifest("1.2.3.4")).toBeNull(); + }); +}); diff --git a/pyproject.toml b/pyproject.toml index cd7f691..b96807a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ classifiers = [ dependencies = [ "playwright>=1.40", "httpx>=0.24", + "cryptography>=41.0", # verify Ed25519 signature on SHA256SUMS before trusting it ] [project.optional-dependencies] diff --git a/tests/test_update.py b/tests/test_update.py index 7ba0861..de0c9f6 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -2,12 +2,14 @@ from __future__ import annotations +import base64 import hashlib import os from pathlib import Path from unittest.mock import MagicMock, patch import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cloakbrowser.config import ( CHROMIUM_VERSION, @@ -22,10 +24,14 @@ from cloakbrowser.download import ( _check_wrapper_update, _download_and_extract, _fetch_checksums, + _fetch_signed_manifest, _get_latest_chromium_version, _parse_checksums, + _parse_manifest_version, _should_check_for_update, _verify_checksum, + _verify_download_checksum, + _verify_signature, _write_version_marker, check_for_update, clear_cache, @@ -486,7 +492,6 @@ class TestDownloadFallback: with patch.dict(os.environ, { "CLOAKBROWSER_CACHE_DIR": str(tmp_path), "CLOAKBROWSER_DOWNLOAD_URL": "", - "CLOAKBROWSER_SKIP_CHECKSUM": "true", }): urls_called = [] @@ -497,7 +502,10 @@ class TestDownloadFallback: # GitHub fallback succeeds dest.write_bytes(b"fake") + # This test exercises URL fallback, not verification — stub the + # (now signature-based, non-bypassable) verify step. with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \ + patch("cloakbrowser.download._verify_download_checksum"), \ patch("cloakbrowser.download._extract_archive"), \ patch("cloakbrowser.download._show_welcome"): _download_and_extract() @@ -548,3 +556,232 @@ class TestDownloadFallback: result = _fetch_checksums() assert result is None + + +# --------------------------------------------------------------------------- +# Signed-manifest verification (Ed25519). Trust root is the pinned public key, +# not the same-origin SHA256SUMS — this is what closes M1 (#308). +# --------------------------------------------------------------------------- +def _make_key(): + priv = Ed25519PrivateKey.generate() + from cryptography.hazmat.primitives import serialization + + raw = priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return priv, base64.b64encode(raw).decode() + + +def _sign(priv, manifest_bytes: bytes) -> bytes: + """Return SHA256SUMS.sig content (base64 of the raw signature), as served.""" + return base64.b64encode(priv.sign(manifest_bytes)) + + +class TestSignatureVerification: + """_verify_signature: the cryptographic gate over the raw manifest bytes.""" + + def test_valid_signature_passes(self): + priv, pub_b64 = _make_key() + manifest = b"abc cloakbrowser-linux-x64.tar.gz\n" + sig = _sign(priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]): + _verify_signature(manifest, sig) # no raise + + def test_tampered_manifest_fails(self): + priv, pub_b64 = _make_key() + manifest = b"abc cloakbrowser-linux-x64.tar.gz\n" + sig = _sign(priv, manifest) + tampered = manifest.replace(b"abc", b"xyz") + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]): + with pytest.raises(RuntimeError, match="signature verification failed"): + _verify_signature(tampered, sig) + + def test_wrong_key_fails(self): + priv, _ = _make_key() + _, other_pub = _make_key() + manifest = b"data\n" + sig = _sign(priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [other_pub]): + with pytest.raises(RuntimeError, match="signature verification failed"): + _verify_signature(manifest, sig) + + def test_malformed_signature_fails(self): + _, pub_b64 = _make_key() + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]): + with pytest.raises(RuntimeError, match="Malformed"): + _verify_signature(b"data\n", b"!!!not base64!!!") + + def test_placeholder_key_is_skipped_not_crashing(self): + """An unparseable pinned key (placeholder) must not abort — a real key still validates.""" + priv, pub_b64 = _make_key() + manifest = b"data\n" + sig = _sign(priv, manifest) + with patch( + "cloakbrowser.download.BINARY_SIGNING_PUBKEYS", + ["REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub_b64], + ): + _verify_signature(manifest, sig) # no raise + + def test_key_rotation_second_key_accepts(self): + """A manifest signed with the new key validates while the old key stays pinned.""" + old_priv, old_pub = _make_key() + new_priv, new_pub = _make_key() + manifest = b"rotated\n" + sig = _sign(new_priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [old_pub, new_pub]): + _verify_signature(manifest, sig) # no raise + + +class TestVerifyDownloadChecksumSigned: + """_verify_download_checksum on the official path: signature + version + hash, fail-closed.""" + + def _hash(self, data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + def _manifest(self, body: str, version: str | None = None) -> bytes: + """Build a signed-manifest body with the bound version line prepended.""" + v = version if version is not None else get_chromium_version() + return f"version={v}\n{body}".encode() + + def test_valid_manifest_and_hash_passes(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real binary") + tarball = get_download_url().rsplit("/", 1)[-1] + manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n") + sig = _sign(priv, manifest) + + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \ + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)): + _verify_download_checksum(archive) # no raise + + def test_tampered_binary_fails_hash(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"a malicious binary") # different bytes + tarball = get_download_url().rsplit("/", 1)[-1] + manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n") + sig = _sign(priv, manifest) + + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \ + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)): + with pytest.raises(RuntimeError, match="Checksum verification failed"): + _verify_download_checksum(archive) + + def test_wrong_version_fails_downgrade(self, tmp_path): + """A genuinely-signed manifest for a DIFFERENT version is rejected (downgrade).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real binary") + tarball = get_download_url().rsplit("/", 1)[-1] + # Manifest declares an old version, but we ask for get_chromium_version(). + manifest = self._manifest( + f"{self._hash(b'the real binary')} {tarball}\n", version="1.0.0.0" + ) + sig = _sign(priv, manifest) + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \ + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)): + with pytest.raises(RuntimeError, match="Version mismatch"): + _verify_download_checksum(archive) + + def test_missing_version_line_fails(self, tmp_path): + """A signed manifest without a version line is rejected (binding required).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real binary") + tarball = get_download_url().rsplit("/", 1)[-1] + manifest = f"{self._hash(b'the real binary')} {tarball}\n".encode() # no version= + sig = _sign(priv, manifest) + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \ + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)): + with pytest.raises(RuntimeError, match="Version mismatch"): + _verify_download_checksum(archive) + + def test_missing_signed_manifest_fails_closed(self, tmp_path): + archive = tmp_path / "binary" + archive.write_bytes(b"x") + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \ + patch("cloakbrowser.download._fetch_signed_manifest", return_value=None): + with pytest.raises(RuntimeError, match="signed SHA256SUMS"): + _verify_download_checksum(archive) + + def test_manifest_without_entry_fails(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"x") + manifest = self._manifest("deadbeef some-other-file.tar.gz\n") # no entry for our tarball + sig = _sign(priv, manifest) + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \ + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \ + patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)): + with pytest.raises(RuntimeError, match="no entry for"): + _verify_download_checksum(archive) + + def test_custom_url_uses_plain_checksum_and_skip(self, tmp_path): + """Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path.""" + archive = tmp_path / "binary" + archive.write_bytes(b"x") + with patch.dict(os.environ, { + "CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.test", + "CLOAKBROWSER_SKIP_CHECKSUM": "true", + }): + # Signature path must NOT be consulted for a custom mirror. + with patch("cloakbrowser.download._fetch_signed_manifest") as mocked: + _verify_download_checksum(archive) # skip honored, no raise + mocked.assert_not_called() + + +class TestVersionBinding: + """The 'version=' line: read by new wrappers, ignored by old parsers.""" + + def test_parse_manifest_version(self): + manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n" + assert _parse_manifest_version(manifest) == "146.0.7680.177.5" + + def test_parse_manifest_version_absent(self): + assert _parse_manifest_version("abc cloakbrowser-linux-x64.tar.gz\n") is None + + def test_old_checksum_parser_ignores_version_line(self): + """Regression: the version line must not pollute the old hash map.""" + h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + manifest = f"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n" + result = _parse_checksums(manifest) + assert result == {"cloakbrowser-linux-x64.tar.gz": h} + + +class TestFetchSignedManifest: + """_fetch_signed_manifest pairs SHA256SUMS + .sig from the same origin.""" + + def test_fetches_both_from_primary(self): + def mock_get(url, **kwargs): + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST" + return resp + + with patch("cloakbrowser.download.httpx.get", side_effect=mock_get): + result = _fetch_signed_manifest("1.2.3.4") + assert result == (b"MANIFEST", b"SIG") + + def test_falls_back_to_github_when_primary_missing_sig(self): + def mock_get(url, **kwargs): + resp = MagicMock() + resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST" + if "cloakbrowser.dev" in url and url.endswith(".sig"): + resp.raise_for_status.side_effect = Exception("404") + else: + resp.raise_for_status = MagicMock() + return resp + + with patch("cloakbrowser.download.httpx.get", side_effect=mock_get): + result = _fetch_signed_manifest("1.2.3.4") + assert result == (b"MANIFEST", b"SIG") + + def test_returns_none_when_all_fail(self): + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network")): + assert _fetch_signed_manifest("1.2.3.4") is None