feat(security): verify binaries with pinned Ed25519 signature on SHA256SUMS

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.
This commit is contained in:
CloakHQ
2026-06-21 02:42:18 +02:00
parent 50bf14b3f9
commit 660b6bf58c
10 changed files with 839 additions and 34 deletions
+5
View File
@@ -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
+1
View File
@@ -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.
+3 -3
View File
@@ -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)
+13
View File
@@ -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
+163 -18
View File
@@ -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=<v>' line from a signed manifest. None if absent.
The line has no internal whitespace so older wrappers' SHA256SUMS parsers
ignore it (they only accept '<hash> <filename>' 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
+13
View File
@@ -37,6 +37,19 @@ export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"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
// ---------------------------------------------------------------------------
+168 -12
View File
@@ -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<void> {
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<void> {
}
}
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
/** @internal Exported for testing only. */
export async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
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=<v>' line from a signed manifest. null if absent.
* The line has no internal whitespace so older wrappers' SHA256SUMS parsers
* ignore it (they only accept '<hash> <filename>' 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. */
+234
View File
@@ -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<typeof import("../src/config.js")>();
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<typeof import("../src/config.js")>();
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<typeof import("../src/config.js")>();
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();
});
});
+1
View File
@@ -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]
+238 -1
View File
@@ -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=<v>' 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