Files
CloakBrowser/js/tests/signature.test.ts
T

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();
});
});