1234567891011121314151617import { beforeAll, describe, expect, it, vi } from "vitest";18import { isProUser, validateLicense } from "./license";192021222324const 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}505152535455let testPrivKey: CryptoKey;56let testPubKeyHex: string;57585960async function buildTestLicense(61 privKey: CryptoKey,62 opts: { tamperPayloadByte?: number; tamperSigByte?: number } = {},63): Promise<string> {64 const payload = new Uint8Array(12);65 payload[0] = 0x01; 66 payload[1] = 0x01; 67 payload[2] = 0x01; 68 const issuedDays = Math.floor(Date.now() / (86400 * 1000));69 new DataView(payload.buffer).setUint32(3, issuedDays, true);70 7172 73 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 91 92 93 const b58 = base58Encode(blob);94 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}99100101102async 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 116 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}157158159160161162describe("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 177 178 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 187 188 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 196 197 198 it("T-009c: tampered payload byte → verification fails", async () => {199 200 const license = await buildTestLicense(testPrivKey, { tamperPayloadByte: 5 });201 const result = await validateLicenseWithKey(license, testPubKeyHex);202 203 204 205 206 expect(result.valid).toBe(false);207 });208209 210 211 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 219 220 it("T-009e: signature too short (truncated blob) → handled gracefully, does not throw", async () => {221 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 it("T-009g: isPro returns false after invalid license storage", async () => {234 vi.stubGlobal("chrome", {235 storage: {236 local: {237 get: vi.fn().mockResolvedValue({ peach_license: "invalid-license" }),238 },239 },240 });241 await expect(isProUser()).resolves.toBe(false);242 vi.unstubAllGlobals();243 });244245 246 it("T-009h: wrong group count → fails before crypto", async () => {247 const result = await validateLicense("PPK-AAAA-BBBB-CCCC-DDDD");248 expect(result.valid).toBe(false);249 });250 it("uses the production verifier with a public-key vector and honors local revocation", async () => {251 const local: Record<string, unknown> = {};252 vi.stubGlobal("chrome", {253 storage: {254 local: {255 get: vi.fn(async (key: string) => ({ [key]: local[key] })),256 set: vi.fn(async (next: Record<string, unknown>) => Object.assign(local, next)),257 },258 },259 });260 const license = await buildTestLicense(testPrivKey);261 const valid = await validateLicense(license, testPubKeyHex);262 expect(valid).toMatchObject({ valid: true, plan: "pro", identifier: 0 });263 const { revokeLicenseIdentifier } = await import("./license");264 await revokeLicenseIdentifier(0);265 await expect(validateLicense(license, testPubKeyHex)).resolves.toMatchObject({ valid: false, plan: "free", identifier: 0 });266 vi.unstubAllGlobals();267 });268});