license.ts

6.3 KB · raw

1const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";23function base58Decode(str: string): Uint8Array {4  let num = 0n;5  for (const char of str) {6    const idx = BASE58_ALPHABET.indexOf(char);7    if (idx === -1) throw new Error("Invalid base58 character");8    num = num * 58n + BigInt(idx);9  }10  const hex = num.toString(16).padStart(2, "0");11  const paddedHex = hex.length % 2 ? "0" + hex : hex;12  const result = new Uint8Array(paddedHex.length / 2);13  for (let i = 0; i < paddedHex.length; i += 2) {14    result[i / 2] = parseInt(paddedHex.slice(i, i + 2), 16);15  }16  return result;17}1819function fromBase58Groups(groups: string[]): string {20  return groups.join("");21}2223export const PUBLIC_KEY_HEX =24  "c7dc52d2c484696cf8566dbe6cfe69025d114cb47f6cf53c25088ecfd539bdfa";2526export interface LicenseInfo {27  valid: boolean;28  plan: "free" | "pro";29  issued?: Date;30  /** Random issuer-assigned identifier embedded in the signed payload. */31  identifier?: number;32}3334const LICENSE_PREFIX = "PPK-";35const LICENSE_REVOCATIONS_KEY = "peach_license_revocations_v1";36const LICENSE_PAYLOAD_LENGTH = 12;37/** Issuer-side helper: embed this random uint32 in the signed payload. */38export function generateLicenseIdentifier(): number {39  const bytes = new Uint32Array(1);40  crypto.getRandomValues(bytes);41  return bytes[0];42}4344export async function revokeLicenseIdentifier(identifier: number): Promise<void> {45  if (!Number.isSafeInteger(identifier) || identifier < 0 || identifier > 0xffffffff) {46    throw new Error("invalid license identifier");47  }48  const result = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;49  const current = Array.isArray(result[LICENSE_REVOCATIONS_KEY])50    ? result[LICENSE_REVOCATIONS_KEY].filter((value): value is number => Number.isSafeInteger(value))51    : [];52  if (!current.includes(identifier)) current.push(identifier);53  await chrome.storage.local.set({ [LICENSE_REVOCATIONS_KEY]: current });54}55// Revocations are profile-local. No issuer update channel is implemented; a56// user must activate a newly issued license on each installation.5758async function importPublicKey(publicKeyHex = PUBLIC_KEY_HEX): Promise<CryptoKey> {59  const keyData = hexToBytes(publicKeyHex);60  return crypto.subtle.importKey(61    "raw",62    keyData.buffer as ArrayBuffer,63    { name: "Ed25519" },64    true,65    ["verify"],66  );67}6869function hexToBytes(hex: string): Uint8Array {70  if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("invalid public key");71  const bytes = new Uint8Array(32);72  for (let i = 0; i < bytes.length; i += 1) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);73  return bytes;74}7576export async function validateLicense(keyString: string, publicKeyHex = PUBLIC_KEY_HEX): Promise<LicenseInfo> {77  if (!keyString || typeof keyString !== "string") return { valid: false, plan: "free" };78  const trimmed = keyString.trim();79  if (!trimmed.startsWith(LICENSE_PREFIX)) return { valid: false, plan: "free" };8081  try {82    const groups = trimmed.slice(LICENSE_PREFIX.length).split("-");83    if (groups.length !== 5) return { valid: false, plan: "free" };84    const decoded = base58Decode(fromBase58Groups(groups));85    if (decoded.length !== 76) return { valid: false, plan: "free" };86    const payload = decoded.slice(0, LICENSE_PAYLOAD_LENGTH);87    const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH);88    if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {89      return { valid: false, plan: "free" };90    }91    const issuedTimestamp = new DataView(payload.buffer, payload.byteOffset + 3, 4).getUint32(0, true);92    const identifier = new DataView(payload.buffer, payload.byteOffset + 7, 4).getUint32(0, true);93    const publicKey = await importPublicKey(publicKeyHex);94    const isValid = await crypto.subtle.verify("Ed25519", publicKey, signature, payload);95    if (!isValid) return { valid: false, plan: "free" };96    const revocations = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;97    if (Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) && revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier)) {98      return { valid: false, plan: "free", identifier };99    }100    return { valid: true, plan: "pro", issued: new Date(issuedTimestamp * 86400 * 1000), identifier };101  } catch {102    return { valid: false, plan: "free" };103  }104}105106export async function getLicenseInfo(): Promise<LicenseInfo> {107  try {108    const result = await chrome.storage.local.get("peach_license") as Record<string, string | undefined>;109    const stored = result["peach_license"];110    if (!stored) {111      return { valid: false, plan: "free" };112    }113    return validateLicense(stored);114  } catch {115    return { valid: false, plan: "free" };116  }117}118119const CELEBRATION_KEY = "peach_pro_celebrated";120121export async function activateLicense(keyString: string): Promise<LicenseInfo> {122  const validated = await validateLicense(keyString);123  if (validated.valid && validated.plan === "pro") {124    // Reset the celebration flag so every new license activation triggers125    // the full-screen Pro celebration in the popup, even on devices that126    // have celebrated before (e.g., testers who activate repeatedly).127    await chrome.storage.local.set({128      peach_license: keyString.trim(),129      [CELEBRATION_KEY]: false,130    });131    return validated;132  }133  return { valid: false, plan: "free" };134}135136export async function isProUser(): Promise<boolean> {137  const info = await getLicenseInfo();138  return info.valid && info.plan === "pro";139}140141/** Returns the raw stored license key string, or null if none. */142export async function getLicenseKey(): Promise<string | null> {143  try {144    const result = await chrome.storage.local.get("peach_license") as Record<string, string | undefined>;145    return result["peach_license"] ?? null;146  } catch {147    return null;148  }149}150151export async function deactivateLicense(): Promise<void> {152  await chrome.storage.local.remove("peach_license");153}154155export async function shouldPlayCelebration(): Promise<boolean> {156  const info = await getLicenseInfo();157  if (!info.valid || info.plan !== "pro") return false;158  const result = await chrome.storage.local.get(CELEBRATION_KEY) as Record<string, boolean | undefined>;159  return !result[CELEBRATION_KEY];160}161162export async function markCelebrationPlayed(): Promise<void> {163  await chrome.storage.local.set({ [CELEBRATION_KEY]: true });164}