license.verification.test.ts

10.1 KB · raw

1// T-009: License Ed25519 signature verification2//3// Why this test exists: validateLicense in license.ts has no direct test.4// The only coverage it gets is via "isPro gate" tests that trigger the5// pro-gate behavior — they don't actually verify that the Ed25519 signature6// is checked correctly. A broken or short-circuited verifier would accept7// any forged license string without a test failing.8//9// Integration contract note: T-009a–c (crypto paths) require the production10// validateLicense to be refactored with an injectable public key, OR to export11// a validateLicenseWithKey(keyString, pubKeyHex) helper. In the current12// codebase, PUBLIC_KEY_HEX is read by a private function; the test below13// uses a local reimplementation with an injected test key so real Ed2551914// crypto is exercised without the production key.15// The structural checks (T-009d/e) test the real export directly.1617import { beforeAll, describe, expect, it } from "vitest";18import { validateLicense } from "./license";1920// ---------------------------------------------------------------------------21// Base58 helpers (mirrored from license.ts for test use)22// ---------------------------------------------------------------------------2324const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";2526function base58Encode(bytes: Uint8Array): string {27  let num = 0n;28  for (const b of bytes) num = num * 256n + BigInt(b);29  let out = "";30  while (num > 0n) {31    out = BASE58_ALPHABET[Number(num % 58n)] + out;32    num = num / 58n;33  }34  for (const b of bytes) {35    if (b !== 0) break;36    out = "1" + out;37  }38  return out;39}4041function bytesToHex(bytes: Uint8Array): string {42  return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");43}4445function hexToBytes(hex: string): Uint8Array {46  const out = new Uint8Array(hex.length / 2);47  for (let i = 0; i < hex.length; i += 2) out[i / 2] = parseInt(hex.slice(i, i + 2), 16);48  return out;49}5051// ---------------------------------------------------------------------------52// Test keypair and license builder53// ---------------------------------------------------------------------------5455let testPrivKey: CryptoKey;56let testPubKeyHex: string;5758// Build a valid 76-byte license blob: 12-byte payload + 64-byte Ed25519 sig.59// payload: [0x01, 0x01, 0x01, days(4 bytes LE), 0x00×5]60async function buildTestLicense(61  privKey: CryptoKey,62  opts: { tamperPayloadByte?: number; tamperSigByte?: number } = {},63): Promise<string> {64  const payload = new Uint8Array(12);65  payload[0] = 0x01; // version66  payload[1] = 0x01; // plan marker67  payload[2] = 0x01; // variant marker68  const issuedDays = Math.floor(Date.now() / (86400 * 1000));69  new DataView(payload.buffer).setUint32(3, issuedDays, true);70  // bytes 7–11: reserved zeros7172  // Sign the original payload first; tamper happens on the assembled blob below73  // so the signature binds to the un-tampered bytes, causing verify to fail.74  const sigBytes = new Uint8Array(75    await crypto.subtle.sign("Ed25519", privKey, payload),76  );7778  if (opts.tamperSigByte !== undefined) {79    sigBytes[opts.tamperSigByte] ^= 0x01;80  }8182  const blob = new Uint8Array(76);83  blob.set(payload, 0);84  blob.set(sigBytes, 12);8586  if (opts.tamperPayloadByte !== undefined) {87    blob[opts.tamperPayloadByte] ^= 0x01;88  }8990  // Format as "PPK-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE"91  // The actual license format is 5 base58 groups; for testing we can use92  // a single continuous base58 string split into 5 equal groups.93  const b58 = base58Encode(blob);94  // Pad to ensure we can split into 5 groups (actual production code joins them)95  const chunk = Math.ceil(b58.length / 5);96  const groups = Array.from({ length: 5 }, (_, i) => b58.slice(i * chunk, (i + 1) * chunk));97  return "PPK-" + groups.join("-");98}99100// validateLicense variant that uses a test public key instead of the production key.101// This re-implements the license.ts logic with an injected key for test isolation.102async function validateLicenseWithKey(103  keyString: string,104  pubKeyHex: string,105): Promise<{ valid: boolean; plan: string }> {106  const LICENSE_PREFIX = "PPK-";107  if (!keyString || !keyString.trim().startsWith(LICENSE_PREFIX)) {108    return { valid: false, plan: "free" };109  }110  try {111    const withoutPrefix = keyString.trim().slice(LICENSE_PREFIX.length);112    const groups = withoutPrefix.split("-");113    if (groups.length !== 5) return { valid: false, plan: "free" };114    const b58 = groups.join("");115    // base58 decode116    let num = 0n;117    for (const char of b58) {118      const idx = BASE58_ALPHABET.indexOf(char);119      if (idx === -1) return { valid: false, plan: "free" };120      num = num * 58n + BigInt(idx);121    }122    const hexStr = num.toString(16);123    const paddedHex = hexStr.length % 2 ? "0" + hexStr : hexStr;124    const decoded = new Uint8Array(paddedHex.length / 2);125    for (let i = 0; i < paddedHex.length; i += 2) {126      decoded[i / 2] = parseInt(paddedHex.slice(i, i + 2), 16);127    }128    if (decoded.length !== 76) return { valid: false, plan: "free" };129130    const payload = decoded.slice(0, 12);131    const signature = decoded.slice(12, 76);132133    if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {134      return { valid: false, plan: "free" };135    }136137    const pubKey = await (crypto.subtle as unknown as Record<string, Function>).importKey(138      "raw",139      hexToBytes(pubKeyHex),140      { name: "Ed25519" },141      true,142      ["verify"],143    ) as CryptoKey;144145    const isValid = await (crypto.subtle as unknown as Record<string, Function>).verify(146      "Ed25519",147      pubKey,148      signature,149      payload,150    ) as boolean;151152    return isValid ? { valid: true, plan: "pro" } : { valid: false, plan: "free" };153  } catch {154    return { valid: false, plan: "free" };155  }156}157158// ---------------------------------------------------------------------------159// Tests160// ---------------------------------------------------------------------------161162describe("T-009: License Ed25519 signature verification", () => {163  beforeAll(async () => {164    const kp = await crypto.subtle.generateKey(165      "Ed25519",166      true,167      ["sign", "verify"],168    );169    testPrivKey = (kp as CryptoKeyPair).privateKey;170    const rawPub = new Uint8Array(171      await crypto.subtle.exportKey("raw", (kp as CryptoKeyPair).publicKey),172    );173    testPubKeyHex = bytesToHex(rawPub);174  });175176  // T-009a: valid license payload + valid Ed25519 signature → verification succeeds.177  // Bug this catches: a broken verifier (wrong algorithm, missing verify call,178  // always-false return) would reject all licenses.179  it("T-009a: valid license payload + valid Ed25519 signature → verification succeeds", async () => {180    const license = await buildTestLicense(testPrivKey);181    const result = await validateLicenseWithKey(license, testPubKeyHex);182    expect(result.valid).toBe(true);183    expect(result.plan).toBe("pro");184  });185186  // T-009b: flipping the last byte of the signature invalidates it.187  // Bug this catches: a verifier that doesn't call crypto.subtle.verify, or188  // that ignores the result, would accept this tampered signature.189  it("T-009b: tampered last byte of signature → verification fails", async () => {190    const license = await buildTestLicense(testPrivKey, { tamperSigByte: 63 });191    const result = await validateLicenseWithKey(license, testPubKeyHex);192    expect(result.valid).toBe(false);193  });194195  // T-009c: a single changed byte in the payload invalidates the signature.196  // Bug this catches: a verifier that doesn't bind the signature to the payload197  // would accept a payload with different data (e.g., injected plan byte).198  it("T-009c: tampered payload byte → verification fails", async () => {199    // Tamper byte 5 (part of the issuedDays field — always non-zero for recent dates)200    const license = await buildTestLicense(testPrivKey, { tamperPayloadByte: 5 });201    const result = await validateLicenseWithKey(license, testPubKeyHex);202    // The signature was computed over the original payload; a changed payload203    // produces a mismatch.204    // Note: if the tampered byte happens to produce the same value (XOR 0x01 on 0x00205    // changes to 0x01, but the odds of the verify still passing are astronomically small).206    expect(result.valid).toBe(false);207  });208209  // T-009d: wrong prefix — tests the real validateLicense export.210  // The prefix check fires before any base58 decode or crypto, so this is211  // safe to test against the production export.212  it("T-009d: license without PPK- prefix → validation fails before signature check", async () => {213    const result = await validateLicense("NOTPPK-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE");214    expect(result.valid).toBe(false);215    expect(result.plan).toBe("free");216  });217218  // T-009e: signature too short (truncated to < 64 bytes) — must be handled219  // gracefully. This tests the 76-byte length check and the try/catch.220  it("T-009e: signature too short (truncated blob) → handled gracefully, does not throw", async () => {221    // Build a blob that's only 40 bytes (shorter than 76) and base58-encode it.222    const short = new Uint8Array(40).fill(0x01);223    const b58 = base58Encode(short);224    const chunk = Math.ceil(b58.length / 5);225    const groups = Array.from({ length: 5 }, (_, i) => b58.slice(i * chunk, (i + 1) * chunk));226    const license = "PPK-" + groups.join("-");227228    const result = await validateLicense(license);229    expect(result.valid).toBe(false);230    expect(result.plan).toBe("free");231  });232233  // T-009f: isPro returns true after a valid license is stored.234  // This wires validateLicense to the chrome.storage path via getLicenseInfo.235  // Integration contract: this test requires a chrome.storage mock.236  // (Skipped in the current draft — add chrome mock via the vault test pattern.)237  it.todo("T-009f: isPro returns true after valid license storage (requires chrome mock)");238239  // T-009g: isPro returns false after invalid license verification.240  it.todo("T-009g: isPro returns false after invalid license (requires chrome mock)");241242  // T-009h: wrong group count (4 groups instead of 5) is caught before crypto.243  it("T-009h: wrong group count → fails before crypto", async () => {244    const result = await validateLicense("PPK-AAAA-BBBB-CCCC-DDDD");245    expect(result.valid).toBe(false);246  });247});