mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
@@ -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
|
||||||
@@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]** 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.
|
- **[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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
|
||||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
||||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
| `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_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_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 |
|
| `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
|
## 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
|
```bash
|
||||||
# Verify GPG signature (binary release tag)
|
# Verify GPG signature (binary release tag)
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
|||||||
"windows-x64": "146.0.7680.177.5",
|
"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.
|
# Playwright default args to suppress — these leak automation signals.
|
||||||
# --enable-automation: exposes navigator.webdriver = true
|
# --enable-automation: exposes navigator.webdriver = true
|
||||||
|
|||||||
+159
-14
@@ -23,6 +23,7 @@ import httpx
|
|||||||
|
|
||||||
from ._version import __version__ as _wrapper_version
|
from ._version import __version__ as _wrapper_version
|
||||||
from .config import (
|
from .config import (
|
||||||
|
BINARY_SIGNING_PUBKEYS,
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
DOWNLOAD_BASE_URL,
|
DOWNLOAD_BASE_URL,
|
||||||
GITHUB_API_URL,
|
GITHUB_API_URL,
|
||||||
@@ -162,8 +163,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
|
# Verify the download before extraction. On the official path this is a
|
||||||
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
|
# 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)
|
_verify_download_checksum(tmp_path, version)
|
||||||
|
|
||||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||||
@@ -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:
|
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."""
|
"""Verify the downloaded archive's integrity and authenticity.
|
||||||
checksums = _fetch_checksums(version)
|
|
||||||
|
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()
|
tarball_name = get_archive_name()
|
||||||
|
|
||||||
|
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:
|
if checksums is None:
|
||||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
logger.warning(
|
||||||
|
"SHA256SUMS not available from custom URL — skipping checksum verification"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
expected = checksums.get(tarball_name)
|
expected = checksums.get(tarball_name)
|
||||||
if expected is None:
|
if expected is None:
|
||||||
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
|
logger.warning(
|
||||||
|
"SHA256SUMS found but no entry for %s — skipping verification", tarball_name
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_verify_checksum(file_path, expected)
|
||||||
return
|
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:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Signature-verified SHA256SUMS has no entry for {tarball_name} — "
|
||||||
|
f"cannot confirm binary integrity."
|
||||||
|
)
|
||||||
_verify_checksum(file_path, expected)
|
_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:
|
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
|
||||||
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
||||||
v = version or get_chromium_version()
|
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]:
|
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 = {}
|
result = {}
|
||||||
for line in text.strip().splitlines():
|
for line in text.strip().splitlines():
|
||||||
line = line.strip()
|
parts = line.strip().split(None, 1)
|
||||||
if not line:
|
if len(parts) != 2:
|
||||||
continue
|
continue
|
||||||
parts = line.split(None, 1)
|
|
||||||
if len(parts) == 2:
|
|
||||||
hash_val, filename = parts
|
hash_val, filename = parts
|
||||||
filename = filename.lstrip("*")
|
hash_val = hash_val.lower()
|
||||||
result[filename] = 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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
|||||||
"windows-x64": "146.0.7680.177.5",
|
"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
|
// Platform detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+166
-10
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
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 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,6 +14,7 @@ import { extract as tarExtract } from "tar";
|
|||||||
|
|
||||||
import type { BinaryInfo } from "./types.js";
|
import type { BinaryInfo } from "./types.js";
|
||||||
import {
|
import {
|
||||||
|
BINARY_SIGNING_PUBKEYS,
|
||||||
DOWNLOAD_BASE_URL,
|
DOWNLOAD_BASE_URL,
|
||||||
GITHUB_API_URL,
|
GITHUB_API_URL,
|
||||||
GITHUB_DOWNLOAD_BASE_URL,
|
GITHUB_DOWNLOAD_BASE_URL,
|
||||||
@@ -195,10 +196,11 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
|||||||
await downloadFile(fallbackUrl, tmpPath);
|
await downloadFile(fallbackUrl, tmpPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checksum before extraction
|
// Verify the download before extraction. On the official path this is a
|
||||||
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
|
// 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 verifyDownloadChecksum(tmpPath, version);
|
||||||
}
|
|
||||||
|
|
||||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||||
showWelcome();
|
showWelcome();
|
||||||
@@ -210,22 +212,176 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
/** @internal Exported for testing only. */
|
||||||
const checksums = await fetchChecksums(version);
|
export async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||||
const tarballName = getArchiveName();
|
const tarballName = getArchiveName();
|
||||||
|
|
||||||
|
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) {
|
if (!checksums) {
|
||||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
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;
|
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);
|
const expected = checksums.get(tarballName);
|
||||||
if (!expected) {
|
if (!expected) {
|
||||||
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
|
throw new Error(
|
||||||
return;
|
`Signature-verified SHA256SUMS has no entry for ${tarballName} — ` +
|
||||||
|
`cannot confirm binary integrity.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await verifyChecksum(filePath, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)");
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** @internal Exported for testing only. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"playwright>=1.40",
|
"playwright>=1.40",
|
||||||
"httpx>=0.24",
|
"httpx>=0.24",
|
||||||
|
"cryptography>=41.0", # verify Ed25519 signature on SHA256SUMS before trusting it
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+238
-1
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
from cloakbrowser.config import (
|
from cloakbrowser.config import (
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
@@ -22,10 +24,14 @@ from cloakbrowser.download import (
|
|||||||
_check_wrapper_update,
|
_check_wrapper_update,
|
||||||
_download_and_extract,
|
_download_and_extract,
|
||||||
_fetch_checksums,
|
_fetch_checksums,
|
||||||
|
_fetch_signed_manifest,
|
||||||
_get_latest_chromium_version,
|
_get_latest_chromium_version,
|
||||||
_parse_checksums,
|
_parse_checksums,
|
||||||
|
_parse_manifest_version,
|
||||||
_should_check_for_update,
|
_should_check_for_update,
|
||||||
_verify_checksum,
|
_verify_checksum,
|
||||||
|
_verify_download_checksum,
|
||||||
|
_verify_signature,
|
||||||
_write_version_marker,
|
_write_version_marker,
|
||||||
check_for_update,
|
check_for_update,
|
||||||
clear_cache,
|
clear_cache,
|
||||||
@@ -486,7 +492,6 @@ class TestDownloadFallback:
|
|||||||
with patch.dict(os.environ, {
|
with patch.dict(os.environ, {
|
||||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||||
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
||||||
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
|
||||||
}):
|
}):
|
||||||
urls_called = []
|
urls_called = []
|
||||||
|
|
||||||
@@ -497,7 +502,10 @@ class TestDownloadFallback:
|
|||||||
# GitHub fallback succeeds
|
# GitHub fallback succeeds
|
||||||
dest.write_bytes(b"fake")
|
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), \
|
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._extract_archive"), \
|
||||||
patch("cloakbrowser.download._show_welcome"):
|
patch("cloakbrowser.download._show_welcome"):
|
||||||
_download_and_extract()
|
_download_and_extract()
|
||||||
@@ -548,3 +556,232 @@ class TestDownloadFallback:
|
|||||||
result = _fetch_checksums()
|
result = _fetch_checksums()
|
||||||
|
|
||||||
assert result is None
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user