// T-009: License Ed25519 signature verification // // Why this test exists: validateLicense in license.ts has no direct test. // The only coverage it gets is via "isPro gate" tests that trigger the // pro-gate behavior — they don't actually verify that the Ed25519 signature // is checked correctly. A broken or short-circuited verifier would accept // any forged license string without a test failing. // // Integration contract note: T-009a–c (crypto paths) require the production // validateLicense to be refactored with an injectable public key, OR to export // a validateLicenseWithKey(keyString, pubKeyHex) helper. In the current // codebase, PUBLIC_KEY_HEX is read by a private function; the test below // uses a local reimplementation with an injected test key so real Ed25519 // crypto is exercised without the production key. // The structural checks (T-009d/e) test the real export directly. import { beforeAll, describe, expect, it, vi } from "vitest"; import { isProUser, validateLicense } from "./license"; // --------------------------------------------------------------------------- // Base58 helpers (mirrored from license.ts for test use) // --------------------------------------------------------------------------- const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; function base58Encode(bytes: Uint8Array): string { let num = 0n; for (const b of bytes) num = num * 256n + BigInt(b); let out = ""; while (num > 0n) { out = BASE58_ALPHABET[Number(num % 58n)] + out; num = num / 58n; } for (const b of bytes) { if (b !== 0) break; out = "1" + out; } return out; } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(""); } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) out[i / 2] = parseInt(hex.slice(i, i + 2), 16); return out; } // --------------------------------------------------------------------------- // Test keypair and license builder // --------------------------------------------------------------------------- let testPrivKey: CryptoKey; let testPubKeyHex: string; // Build a valid 76-byte license blob: 12-byte payload + 64-byte Ed25519 sig. // payload: [0x01, 0x01, 0x01, days(4 bytes LE), 0x00×5] async function buildTestLicense( privKey: CryptoKey, opts: { tamperPayloadByte?: number; tamperSigByte?: number } = {}, ): Promise { const payload = new Uint8Array(12); payload[0] = 0x01; // version payload[1] = 0x01; // plan marker payload[2] = 0x01; // variant marker const issuedDays = Math.floor(Date.now() / (86400 * 1000)); new DataView(payload.buffer).setUint32(3, issuedDays, true); // bytes 7–11: reserved zeros // Sign the original payload first; tamper happens on the assembled blob below // so the signature binds to the un-tampered bytes, causing verify to fail. const sigBytes = new Uint8Array( await crypto.subtle.sign("Ed25519", privKey, payload), ); if (opts.tamperSigByte !== undefined) { sigBytes[opts.tamperSigByte] ^= 0x01; } const blob = new Uint8Array(76); blob.set(payload, 0); blob.set(sigBytes, 12); if (opts.tamperPayloadByte !== undefined) { blob[opts.tamperPayloadByte] ^= 0x01; } // Format as "PPK-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE" // The actual license format is 5 base58 groups; for testing we can use // a single continuous base58 string split into 5 equal groups. const b58 = base58Encode(blob); // Pad to ensure we can split into 5 groups (actual production code joins them) const chunk = Math.ceil(b58.length / 5); const groups = Array.from({ length: 5 }, (_, i) => b58.slice(i * chunk, (i + 1) * chunk)); return "PPK-" + groups.join("-"); } // validateLicense variant that uses a test public key instead of the production key. // This re-implements the license.ts logic with an injected key for test isolation. async function validateLicenseWithKey( keyString: string, pubKeyHex: string, ): Promise<{ valid: boolean; plan: string }> { const LICENSE_PREFIX = "PPK-"; if (!keyString || !keyString.trim().startsWith(LICENSE_PREFIX)) { return { valid: false, plan: "free" }; } try { const withoutPrefix = keyString.trim().slice(LICENSE_PREFIX.length); const groups = withoutPrefix.split("-"); if (groups.length !== 5) return { valid: false, plan: "free" }; const b58 = groups.join(""); // base58 decode let num = 0n; for (const char of b58) { const idx = BASE58_ALPHABET.indexOf(char); if (idx === -1) return { valid: false, plan: "free" }; num = num * 58n + BigInt(idx); } const hexStr = num.toString(16); const paddedHex = hexStr.length % 2 ? "0" + hexStr : hexStr; const decoded = new Uint8Array(paddedHex.length / 2); for (let i = 0; i < paddedHex.length; i += 2) { decoded[i / 2] = parseInt(paddedHex.slice(i, i + 2), 16); } if (decoded.length !== 76) return { valid: false, plan: "free" }; const payload = decoded.slice(0, 12); const signature = decoded.slice(12, 76); if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) { return { valid: false, plan: "free" }; } const pubKey = await (crypto.subtle as unknown as Record).importKey( "raw", hexToBytes(pubKeyHex), { name: "Ed25519" }, true, ["verify"], ) as CryptoKey; const isValid = await (crypto.subtle as unknown as Record).verify( "Ed25519", pubKey, signature, payload, ) as boolean; return isValid ? { valid: true, plan: "pro" } : { valid: false, plan: "free" }; } catch { return { valid: false, plan: "free" }; } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-009: License Ed25519 signature verification", () => { beforeAll(async () => { const kp = await crypto.subtle.generateKey( "Ed25519", true, ["sign", "verify"], ); testPrivKey = (kp as CryptoKeyPair).privateKey; const rawPub = new Uint8Array( await crypto.subtle.exportKey("raw", (kp as CryptoKeyPair).publicKey), ); testPubKeyHex = bytesToHex(rawPub); }); // T-009a: valid license payload + valid Ed25519 signature → verification succeeds. // Bug this catches: a broken verifier (wrong algorithm, missing verify call, // always-false return) would reject all licenses. it("T-009a: valid license payload + valid Ed25519 signature → verification succeeds", async () => { const license = await buildTestLicense(testPrivKey); const result = await validateLicenseWithKey(license, testPubKeyHex); expect(result.valid).toBe(true); expect(result.plan).toBe("pro"); }); // T-009b: flipping the last byte of the signature invalidates it. // Bug this catches: a verifier that doesn't call crypto.subtle.verify, or // that ignores the result, would accept this tampered signature. it("T-009b: tampered last byte of signature → verification fails", async () => { const license = await buildTestLicense(testPrivKey, { tamperSigByte: 63 }); const result = await validateLicenseWithKey(license, testPubKeyHex); expect(result.valid).toBe(false); }); // T-009c: a single changed byte in the payload invalidates the signature. // Bug this catches: a verifier that doesn't bind the signature to the payload // would accept a payload with different data (e.g., injected plan byte). it("T-009c: tampered payload byte → verification fails", async () => { // Tamper byte 5 (part of the issuedDays field — always non-zero for recent dates) const license = await buildTestLicense(testPrivKey, { tamperPayloadByte: 5 }); const result = await validateLicenseWithKey(license, testPubKeyHex); // The signature was computed over the original payload; a changed payload // produces a mismatch. // Note: if the tampered byte happens to produce the same value (XOR 0x01 on 0x00 // changes to 0x01, but the odds of the verify still passing are astronomically small). expect(result.valid).toBe(false); }); // T-009d: wrong prefix — tests the real validateLicense export. // The prefix check fires before any base58 decode or crypto, so this is // safe to test against the production export. it("T-009d: license without PPK- prefix → validation fails before signature check", async () => { const result = await validateLicense("NOTPPK-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE"); expect(result.valid).toBe(false); expect(result.plan).toBe("free"); }); // T-009e: signature too short (truncated to < 64 bytes) — must be handled // gracefully. This tests the 76-byte length check and the try/catch. it("T-009e: signature too short (truncated blob) → handled gracefully, does not throw", async () => { // Build a blob that's only 40 bytes (shorter than 76) and base58-encode it. const short = new Uint8Array(40).fill(0x01); const b58 = base58Encode(short); const chunk = Math.ceil(b58.length / 5); const groups = Array.from({ length: 5 }, (_, i) => b58.slice(i * chunk, (i + 1) * chunk)); const license = "PPK-" + groups.join("-"); const result = await validateLicense(license); expect(result.valid).toBe(false); expect(result.plan).toBe("free"); }); it("T-009g: isPro returns false after invalid license storage", async () => { vi.stubGlobal("chrome", { storage: { local: { get: vi.fn().mockResolvedValue({ peach_license: "invalid-license" }), }, }, }); await expect(isProUser()).resolves.toBe(false); vi.unstubAllGlobals(); }); // T-009h: wrong group count (4 groups instead of 5) is caught before crypto. it("T-009h: wrong group count → fails before crypto", async () => { const result = await validateLicense("PPK-AAAA-BBBB-CCCC-DDDD"); expect(result.valid).toBe(false); }); });