license.verification.test.ts

9.9 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 "../../background/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  if (opts.tamperPayloadByte !== undefined) {73    payload[opts.tamperPayloadByte] ^= 0x01;74  }7576  const sigBytes = new Uint8Array(77    await crypto.subtle.sign("Ed25519", privKey, payload),78  );7980  if (opts.tamperSigByte !== undefined) {81    sigBytes[opts.tamperSigByte] ^= 0x01;82  }8384  const blob = new Uint8Array(76);85  blob.set(payload, 0);86  blob.set(sigBytes, 12);8788  // Format as "PPK-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE"89  // The actual license format is 5 base58 groups; for testing we can use90  // a single continuous base58 string split into 5 equal groups.91  const b58 = base58Encode(blob);92  // Pad to ensure we can split into 5 groups (actual production code joins them)93  const chunk = Math.ceil(b58.length / 5);94  const groups = Array.from({ length: 5 }, (_, i) => b58.slice(i * chunk, (i + 1) * chunk));95  return "PPK-" + groups.join("-");96}9798// validateLicense variant that uses a test public key instead of the production key.99// This re-implements the license.ts logic with an injected key for test isolation.100async function validateLicenseWithKey(101  keyString: string,102  pubKeyHex: string,103): Promise<{ valid: boolean; plan: string }> {104  const LICENSE_PREFIX = "PPK-";105  if (!keyString || !keyString.trim().startsWith(LICENSE_PREFIX)) {106    return { valid: false, plan: "free" };107  }108  try {109    const withoutPrefix = keyString.trim().slice(LICENSE_PREFIX.length);110    const groups = withoutPrefix.split("-");111    if (groups.length !== 5) return { valid: false, plan: "free" };112    const b58 = groups.join("");113    // base58 decode114    let num = 0n;115    for (const char of b58) {116      const idx = BASE58_ALPHABET.indexOf(char);117      if (idx === -1) return { valid: false, plan: "free" };118      num = num * 58n + BigInt(idx);119    }120    const hexStr = num.toString(16);121    const paddedHex = hexStr.length % 2 ? "0" + hexStr : hexStr;122    const decoded = new Uint8Array(paddedHex.length / 2);123    for (let i = 0; i < paddedHex.length; i += 2) {124      decoded[i / 2] = parseInt(paddedHex.slice(i, i + 2), 16);125    }126    if (decoded.length !== 76) return { valid: false, plan: "free" };127128    const payload = decoded.slice(0, 12);129    const signature = decoded.slice(12, 76);130131    if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {132      return { valid: false, plan: "free" };133    }134135    const pubKey = await (crypto.subtle as Record<string, Function>).importKey(136      "raw",137      hexToBytes(pubKeyHex),138      { name: "Ed25519" },139      true,140      ["verify"],141    ) as CryptoKey;142143    const isValid = await (crypto.subtle as Record<string, Function>).verify(144      "Ed25519",145      pubKey,146      signature,147      payload,148    ) as boolean;149150    return isValid ? { valid: true, plan: "pro" } : { valid: false, plan: "free" };151  } catch {152    return { valid: false, plan: "free" };153  }154}155156// ---------------------------------------------------------------------------157// Tests158// ---------------------------------------------------------------------------159160describe("T-009: License Ed25519 signature verification", () => {161  beforeAll(async () => {162    const kp = await crypto.subtle.generateKey(163      "Ed25519",164      true,165      ["sign", "verify"],166    );167    testPrivKey = (kp as CryptoKeyPair).privateKey;168    const rawPub = new Uint8Array(169      await crypto.subtle.exportKey("raw", (kp as CryptoKeyPair).publicKey),170    );171    testPubKeyHex = bytesToHex(rawPub);172  });173174  // T-009a: valid license payload + valid Ed25519 signature → verification succeeds.175  // Bug this catches: a broken verifier (wrong algorithm, missing verify call,176  // always-false return) would reject all licenses.177  it("T-009a: valid license payload + valid Ed25519 signature → verification succeeds", async () => {178    const license = await buildTestLicense(testPrivKey);179    const result = await validateLicenseWithKey(license, testPubKeyHex);180    expect(result.valid).toBe(true);181    expect(result.plan).toBe("pro");182  });183184  // T-009b: flipping the last byte of the signature invalidates it.185  // Bug this catches: a verifier that doesn't call crypto.subtle.verify, or186  // that ignores the result, would accept this tampered signature.187  it("T-009b: tampered last byte of signature → verification fails", async () => {188    const license = await buildTestLicense(testPrivKey, { tamperSigByte: 63 });189    const result = await validateLicenseWithKey(license, testPubKeyHex);190    expect(result.valid).toBe(false);191  });192193  // T-009c: a single changed byte in the payload invalidates the signature.194  // Bug this catches: a verifier that doesn't bind the signature to the payload195  // would accept a payload with different data (e.g., injected plan byte).196  it("T-009c: tampered payload byte → verification fails", async () => {197    // Tamper byte 5 (part of the issuedDays field — always non-zero for recent dates)198    const license = await buildTestLicense(testPrivKey, { tamperPayloadByte: 5 });199    const result = await validateLicenseWithKey(license, testPubKeyHex);200    // The signature was computed over the original payload; a changed payload201    // produces a mismatch.202    // Note: if the tampered byte happens to produce the same value (XOR 0x01 on 0x00203    // changes to 0x01, but the odds of the verify still passing are astronomically small).204    expect(result.valid).toBe(false);205  });206207  // T-009d: wrong prefix — tests the real validateLicense export.208  // The prefix check fires before any base58 decode or crypto, so this is209  // safe to test against the production export.210  it("T-009d: license without PPK- prefix → validation fails before signature check", async () => {211    const result = await validateLicense("NOTPPK-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE");212    expect(result.valid).toBe(false);213    expect(result.plan).toBe("free");214  });215216  // T-009e: signature too short (truncated to < 64 bytes) — must be handled217  // gracefully. This tests the 76-byte length check and the try/catch.218  it("T-009e: signature too short (truncated blob) → handled gracefully, does not throw", async () => {219    // Build a blob that's only 40 bytes (shorter than 76) and base58-encode it.220    const short = new Uint8Array(40).fill(0x01);221    const b58 = base58Encode(short);222    const chunk = Math.ceil(b58.length / 5);223    const groups = Array.from({ length: 5 }, (_, i) => b58.slice(i * chunk, (i + 1) * chunk));224    const license = "PPK-" + groups.join("-");225226    const result = await validateLicense(license);227    expect(result.valid).toBe(false);228    expect(result.plan).toBe("free");229  });230231  // T-009f: isPro returns true after a valid license is stored.232  // This wires validateLicense to the chrome.storage path via getLicenseInfo.233  // Integration contract: this test requires a chrome.storage mock.234  // (Skipped in the current draft — add chrome mock via the vault test pattern.)235  it.todo("T-009f: isPro returns true after valid license storage (requires chrome mock)");236237  // T-009g: isPro returns false after invalid license verification.238  it.todo("T-009g: isPro returns false after invalid license (requires chrome mock)");239240  // T-009h: wrong group count (4 groups instead of 5) is caught before crypto.241  it("T-009h: wrong group count → fails before crypto", async () => {242    const result = await validateLicense("PPK-AAAA-BBBB-CCCC-DDDD");243    expect(result.valid).toBe(false);244  });245});