license.ts

6.2 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}5556async function importPublicKey(): Promise<CryptoKey> {57  const keyData = hexToBytes(PUBLIC_KEY_HEX);58  return (crypto.subtle as any).importKey(59    "raw",60    keyData,61    { name: "Ed25519" },62    true,63    ["verify"]64  );65}6667function hexToBytes(hex: string): Uint8Array {68  const bytes = new Uint8Array(hex.length / 2);69  for (let i = 0; i < hex.length; i += 2) {70    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);71  }72  return bytes;73}7475export async function validateLicense(keyString: string): Promise<LicenseInfo> {76  if (!keyString || typeof keyString !== "string") {77    return { valid: false, plan: "free" };78  }7980  const trimmed = keyString.trim();81  if (!trimmed.startsWith(LICENSE_PREFIX)) {82    return { valid: false, plan: "free" };83  }8485  try {86    const withoutPrefix = trimmed.slice(LICENSE_PREFIX.length);87    const groups = withoutPrefix.split("-");88    if (groups.length !== 5) {89      return { valid: false, plan: "free" };90    }91    const decoded = base58Decode(fromBase58Groups(groups));92    if (decoded.length !== 76) {93      return { valid: false, plan: "free" };94    }9596    const payload = decoded.slice(0, LICENSE_PAYLOAD_LENGTH);97    const signature = decoded.slice(LICENSE_PAYLOAD_LENGTH, 76);9899    if (payload[0] !== 0x01 || payload[1] !== 0x01 || payload[2] !== 0x01) {100      return { valid: false, plan: "free" };101    }102103    const issuedTimestamp = new DataView(payload.buffer, payload.byteOffset + 3, 4).getUint32(0, true);104    const issuedDate = new Date(issuedTimestamp * 86400 * 1000);105    const identifier = new DataView(payload.buffer, payload.byteOffset + 7, 4).getUint32(0, true);106107    const publicKey = await importPublicKey();108    const isValid = await (crypto.subtle as any).verify(109      "Ed25519",110      publicKey,111      signature,112      payload113    );114115    if (!isValid) {116      return { valid: false, plan: "free" };117    }118119    const revocations = await chrome.storage.local.get(LICENSE_REVOCATIONS_KEY) as Record<string, unknown>;120    const revoked = Array.isArray(revocations[LICENSE_REVOCATIONS_KEY]) &&121      revocations[LICENSE_REVOCATIONS_KEY].some((value) => value === identifier);122    if (revoked) return { valid: false, plan: "free", identifier };123124    return { valid: true, plan: "pro", issued: issuedDate, identifier };125  } catch {126    return { valid: false, plan: "free" };127  }128}129130export async function getLicenseInfo(): Promise<LicenseInfo> {131  try {132    const result = await chrome.storage.local.get("peach_license") as Record<string, string | undefined>;133    const stored = result["peach_license"];134    if (!stored) {135      return { valid: false, plan: "free" };136    }137    return validateLicense(stored);138  } catch {139    return { valid: false, plan: "free" };140  }141}142143const CELEBRATION_KEY = "peach_pro_celebrated";144145export async function activateLicense(keyString: string): Promise<LicenseInfo> {146  const validated = await validateLicense(keyString);147  if (validated.valid && validated.plan === "pro") {148    // Reset the celebration flag so every new license activation triggers149    // the full-screen Pro celebration in the popup, even on devices that150    // have celebrated before (e.g., testers who activate repeatedly).151    await chrome.storage.local.set({152      peach_license: keyString.trim(),153      [CELEBRATION_KEY]: false,154    });155    return validated;156  }157  return { valid: false, plan: "free" };158}159160export async function isProUser(): Promise<boolean> {161  const info = await getLicenseInfo();162  return info.valid && info.plan === "pro";163}164165/** Returns the raw stored license key string, or null if none. */166export async function getLicenseKey(): Promise<string | null> {167  try {168    const result = await chrome.storage.local.get("peach_license") as Record<string, string | undefined>;169    return result["peach_license"] ?? null;170  } catch {171    return null;172  }173}174175export async function deactivateLicense(): Promise<void> {176  await chrome.storage.local.remove("peach_license");177}178179export async function shouldPlayCelebration(): Promise<boolean> {180  const info = await getLicenseInfo();181  if (!info.valid || info.plan !== "pro") return false;182  const result = await chrome.storage.local.get(CELEBRATION_KEY) as Record<string, boolean | undefined>;183  return !result[CELEBRATION_KEY];184}185186export async function markCelebrationPlayed(): Promise<void> {187  await chrome.storage.local.set({ [CELEBRATION_KEY]: true });188}