1234567891011121314151617import { beforeAll, describe, expect, it } from "vitest";18import { validateLicense } from "../../background/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 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 89 90 91 const b58 = base58Encode(blob);92 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}979899100async 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 114 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}155156157158159160describe("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 175 176 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 185 186 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 194 195 196 it("T-009c: tampered payload byte → verification fails", async () => {197 198 const license = await buildTestLicense(testPrivKey, { tamperPayloadByte: 5 });199 const result = await validateLicenseWithKey(license, testPubKeyHex);200 201 202 203 204 expect(result.valid).toBe(false);205 });206207 208 209 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 217 218 it("T-009e: signature too short (truncated blob) → handled gracefully, does not throw", async () => {219 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 232 233 234 235 it.todo("T-009f: isPro returns true after valid license storage (requires chrome mock)");236237 238 it.todo("T-009g: isPro returns false after invalid license (requires chrome mock)");239240 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});