feat: add SHA-256 checksum verification for binary downloads

Fetches SHA256SUMS sidecar file from download server before extraction.
Mismatch = hard error, unavailable = warn and proceed (graceful for old releases).
Respects CLOAKBROWSER_DOWNLOAD_URL contract (no GitHub fallback for custom mirrors).
Skip with CLOAKBROWSER_SKIP_CHECKSUM=true. Both Python and JS wrappers.
This commit is contained in:
CloakHQ
2026-03-02 02:59:25 +01:00
parent 8eb666885f
commit cb08a602b0
5 changed files with 163 additions and 3 deletions
+1 -1
View File
@@ -40,6 +40,6 @@ jobs:
# Binary auto-downloads on first launch # Binary auto-downloads on first launch
``` ```
> Checksums and platform list will be added after binary uploads. > Binary integrity is verified automatically via SHA-256 checksums on download.
> >
> Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD` > Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD`
+1 -1
View File
@@ -20,7 +20,7 @@
Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests. Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests.
- 🔒 **22 source-level C++ patches** — not JS injection, not config flags - 🔒 **26 source-level C++ patches** — not JS injection, not config flags
- 🛡️ **CDP stealth built-in** — powered by [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), hides Playwright's automation signals - 🛡️ **CDP stealth built-in** — powered by [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright), hides Playwright's automation signals
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
+77
View File
@@ -6,6 +6,7 @@ Similar to how Playwright downloads its own bundled Chromium.
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import os import os
import platform import platform
@@ -23,6 +24,7 @@ from .config import (
CHROMIUM_VERSION, CHROMIUM_VERSION,
DOWNLOAD_BASE_URL, DOWNLOAD_BASE_URL,
GITHUB_API_URL, GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
_version_newer, _version_newer,
check_platform_available, check_platform_available,
get_binary_dir, get_binary_dir,
@@ -107,6 +109,7 @@ def _download_and_extract(version: str | None = None) -> None:
Tries the primary server (cloakbrowser.dev) first, falls back to Tries the primary server (cloakbrowser.dev) first, falls back to
GitHub Releases if the primary is unreachable or returns an error. GitHub Releases if the primary is unreachable or returns an error.
Verifies SHA-256 checksum before extraction when available.
""" """
primary_url = get_download_url(version) primary_url = get_download_url(version)
fallback_url = get_fallback_download_url(version) fallback_url = get_fallback_download_url(version)
@@ -133,6 +136,10 @@ def _download_and_extract(version: str | None = None) -> None:
) )
_download_file(fallback_url, tmp_path) _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)
_extract_archive(tmp_path, binary_dir, binary_path) _extract_archive(tmp_path, binary_dir, binary_path)
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.") logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues") logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
@@ -142,6 +149,76 @@ def _download_and_extract(version: str | None = None) -> None:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
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)
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
if checksums is None:
logger.warning("SHA256SUMS not available for this release — 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)
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 CHROMIUM_VERSION
has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL")
# Build URL list — respect custom URL contract (no GitHub fallback)
urls = [f"{DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS"]
if not has_custom_url:
urls.append(f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS")
for url in urls:
try:
resp = httpx.get(url, follow_redirects=True, timeout=10.0)
resp.raise_for_status()
return _parse_checksums(resp.text)
except Exception:
continue
return None
def _parse_checksums(text: str) -> dict[str, str]:
"""Parse SHA256SUMS format: 'hash filename' per line."""
result = {}
for line in text.strip().splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) == 2:
hash_val, filename = parts
filename = filename.lstrip("*")
result[filename] = hash_val.lower()
return result
def _verify_checksum(file_path: Path, expected_hash: str) -> None:
"""Verify SHA-256 of a file. Raises RuntimeError on mismatch."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
actual = sha256.hexdigest().lower()
if actual != expected_hash:
raise RuntimeError(
f"Checksum verification failed!\n"
f" Expected: {expected_hash}\n"
f" Got: {actual}\n"
f" File may be corrupted or tampered with. "
f"Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues"
)
logger.info("Checksum verified: SHA-256 OK")
def _download_file(url: str, dest: Path) -> None: def _download_file(url: str, dest: Path) -> None:
"""Download a file with progress logging.""" """Download a file with progress logging."""
logger.info("Downloading from %s", url) logger.info("Downloading from %s", url)
+1 -1
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests. Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
- 🔒 **22 source-level C++ patches** — not JS injection, not config flags - 🔒 **26 source-level C++ patches** — not JS injection, not config flags
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer - 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
+83
View File
@@ -5,6 +5,7 @@
*/ */
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
@@ -14,7 +15,9 @@ import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js"; import type { BinaryInfo } from "./types.js";
import { import {
CHROMIUM_VERSION, CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL, GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
checkPlatformAvailable, checkPlatformAvailable,
getBinaryDir, getBinaryDir,
getBinaryPath, getBinaryPath,
@@ -164,6 +167,11 @@ async function downloadAndExtract(version?: string): Promise<void> {
await downloadFile(fallbackUrl, tmpPath); await downloadFile(fallbackUrl, tmpPath);
} }
// Verify checksum before extraction
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
await verifyDownloadChecksum(tmpPath, version);
}
await extractArchive(tmpPath, binaryDir, binaryPath); await extractArchive(tmpPath, binaryDir, binaryPath);
console.log( console.log(
`[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.` `[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.`
@@ -182,6 +190,81 @@ async function downloadAndExtract(version?: string): Promise<void> {
} }
} }
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
return;
}
const expected = checksums.get(tarballName);
if (!expected) {
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
return;
}
await verifyChecksum(filePath, expected);
}
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || CHROMIUM_VERSION;
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
// Respect custom URL contract — no GitHub fallback when custom URL is set
const urls = [`${DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`];
if (!hasCustomUrl) {
urls.push(`${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`);
}
for (const url of urls) {
try {
const resp = await fetch(url, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) continue;
return parseChecksums(await resp.text());
} catch {
continue;
}
}
return null;
}
function parseChecksums(text: string): Map<string, string> {
const result = new Map<string, string>();
for (const line of text.trim().split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/);
if (match) {
result.set(match[2]!, match[1]!.toLowerCase());
}
}
return result;
}
async function verifyChecksum(filePath: string, expectedHash: string): Promise<void> {
const hash = createHash("sha256");
const stream = fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
const actual = hash.digest("hex").toLowerCase();
if (actual !== expectedHash) {
throw new Error(
`Checksum verification failed!\n` +
` Expected: ${expectedHash}\n` +
` Got: ${actual}\n` +
` File may be corrupted or tampered with. ` +
`Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues`
);
}
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
}
async function downloadFile(url: string, dest: string): Promise<void> { async function downloadFile(url: string, dest: string): Promise<void> {
console.log(`[cloakbrowser] Downloading from ${url}`); console.log(`[cloakbrowser] Downloading from ${url}`);