mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
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 {
|
|
BinaryVerificationError,
|
|
downloadProBinary,
|
|
fetchSignedManifest,
|
|
parseChecksums,
|
|
parseManifestVersion,
|
|
verifyDownloadChecksum,
|
|
verifyProDownload,
|
|
verifySignature,
|
|
} from "../src/download.js";
|
|
import { DOWNLOAD_BASE_URL, 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("downloadProBinary (version-pinned URL)", () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it("requests the explicit version, not /latest", async () => {
|
|
let capturedUrl = "";
|
|
// First fetch is the binary download; capture its URL then abort the flow
|
|
// before verify/extract by returning a non-ok response.
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
capturedUrl = typeof input === "string" ? input : (input as URL).toString();
|
|
return { ok: false, status: 500, statusText: "stop" } as Response;
|
|
});
|
|
|
|
await downloadProBinary("147.0.1.0", "cb_key").catch(() => {});
|
|
|
|
expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`);
|
|
expect(capturedUrl.endsWith("/latest")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("verifyProDownload (Pro path, fail-closed parity)", () => {
|
|
const PRO_VERSION = "147.0.1.0";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
|
|
});
|
|
|
|
function tmpFile(bytes: Buffer): string {
|
|
const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`);
|
|
fs.writeFileSync(p, bytes);
|
|
return p;
|
|
}
|
|
|
|
/** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */
|
|
function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
const url = typeof input === "string" ? input : (input as URL).toString();
|
|
const out = url.endsWith(".sig") ? sigBytes : manifestBytes;
|
|
return { ok: true, arrayBuffer: async () => out.buffer } as Response;
|
|
});
|
|
}
|
|
|
|
const body = (lines: string, version = PRO_VERSION) =>
|
|
enc(`version=${version}\n${lines}`);
|
|
|
|
it("passes when signature is valid and hash matches", async () => {
|
|
const data = Buffer.from("the real pro binary");
|
|
const file = tmpFile(data);
|
|
const hash = createHash("sha256").update(data).digest("hex");
|
|
mockManifest(body(`${hash} ${getArchiveName()}\n`));
|
|
await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => {
|
|
const file = tmpFile(Buffer.from("a malicious pro binary"));
|
|
const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex");
|
|
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
|
|
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
|
|
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
|
// The error TYPE is the contract the ensureBinary router branches on:
|
|
// BinaryVerificationError => re-throw (never downgrade to free).
|
|
expect(err).toBeInstanceOf(BinaryVerificationError);
|
|
expect(err.message).toMatch(/Checksum verification failed/);
|
|
});
|
|
|
|
it("treats a failed manifest fetch as transient, not tampering", async () => {
|
|
// A failed manifest FETCH must be a plain Error (router falls back to free),
|
|
// NOT a BinaryVerificationError (which the router re-throws as a hard fail).
|
|
const file = tmpFile(Buffer.from("x"));
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
|
|
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err).not.toBeInstanceOf(BinaryVerificationError);
|
|
});
|
|
|
|
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
|
|
const data = Buffer.from("the real pro binary");
|
|
const file = tmpFile(data);
|
|
const hash = createHash("sha256").update(data).digest("hex");
|
|
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
|
|
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
|
expect(err).toBeInstanceOf(BinaryVerificationError);
|
|
expect(err.message).toMatch(/Version mismatch/);
|
|
});
|
|
|
|
it("rejects a manifest tampered after signing", async () => {
|
|
const data = Buffer.from("the real pro binary");
|
|
const file = tmpFile(data);
|
|
const hash = createHash("sha256").update(data).digest("hex");
|
|
const good = body(`${hash} ${getArchiveName()}\n`);
|
|
const sig = sign(good);
|
|
const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz"));
|
|
mockManifest(tampered, sig);
|
|
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
|
expect(err).toBeInstanceOf(BinaryVerificationError);
|
|
expect(err.message).toMatch(/signature verification failed/);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|