/** * LeTissier licence verification — TypeScript / Node. * Used by Crewbox. No dependencies: Node's crypto has Ed25519 built in. * * Copy this file into the app and set PUBLIC_KEY_HEX to your own key * (npm run licence:keys in the letissier.ie repo prints it). */ import crypto from "node:crypto"; /** The studio's licence signing key. Public: safe to ship in a binary. */ export const PUBLIC_KEY_HEX = "REPLACE_WITH_YOUR_PUBLIC_KEY_HEX"; export interface Claims { v: number; key: string; product: string; edition: "standard" | "trial" | string; customer: string; name?: string; seats: number; /** Entitled to builds released at or before this unix time. */ maintUntil: number; /** Check-in deadline for this lease. */ exp: number; machine: string; mode: "online" | "offline" | string; iat: number; jti: string; } export type Status = /** Good to run. */ | "active" /** Licence is fine, but this build is newer than the update entitlement. */ | "update_required" /** Lease lapsed. Check in to renew; app policy decides any grace. */ | "check_in_required" /** A trial that has run out. */ | "expired" /** Token was issued for a different machine. */ | "wrong_machine" /** Signature failed, malformed, or wrong version. */ | "invalid"; export interface Verdict { status: Status; claims?: Claims; /** Seconds until check-in is due; negative once overdue. */ checkInIn?: number; } /** SPKI wrapper so a raw 32-byte key can be used with node:crypto. */ function publicKeyFromHex(hex: string): crypto.KeyObject { const der = Buffer.concat([ Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(hex, "hex"), ]); return crypto.createPublicKey({ key: der, format: "der", type: "spki" }); } /** * Must match the server exactly: sha256 of the trimmed fingerprint, first 32 * hex characters. */ export function machineHash(fingerprint: string): string { return crypto.createHash("sha256").update(fingerprint.trim()).digest("hex").slice(0, 32); } /** Verify the signature and parse the claims. No clock or machine checks. */ export function verify(token: string, publicKeyHex: string = PUBLIC_KEY_HEX): Claims | null { const parts = token.split("."); if (parts.length !== 2) { return null; } try { const ok = crypto.verify( null, Buffer.from(parts[0]), publicKeyFromHex(publicKeyHex), Buffer.from(parts[1], "base64url"), ); if (!ok) { return null; } const claims = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")) as Claims; return claims.v === 1 ? claims : null; } catch { return null; } } /** * The whole decision, offline. * * @param buildDate When THIS build was released (unix seconds). Bake it in at * compile time. This is what makes "a year of updates, yours * to keep" work with no server: an older build stays entitled * forever, a newer one asks for a renewal. */ export function check(options: { token: string; fingerprint: string; buildDate: number; now?: number; publicKeyHex?: string; }): Verdict { const claims = verify(options.token, options.publicKeyHex ?? PUBLIC_KEY_HEX); if (!claims) { return { status: "invalid" }; } if (claims.machine !== machineHash(options.fingerprint)) { return { status: "wrong_machine", claims }; } const now = options.now ?? Math.floor(Date.now() / 1000); const checkInIn = claims.exp - now; if (checkInIn <= 0) { // A trial's lease is its lifetime, so a lapsed trial is simply over. return { status: claims.edition === "trial" ? "expired" : "check_in_required", claims, checkInIn }; } if (options.buildDate > claims.maintUntil) { return { status: "update_required", claims, checkInIn }; } return { status: "active", claims, checkInIn }; } // --------------------------------------------------------------------------- // Activation client // --------------------------------------------------------------------------- export interface ClientOptions { baseUrl?: string; fingerprint: string; publicKeyHex?: string; } export class LicenceClient { private readonly baseUrl: string; private readonly fingerprint: string; // Plain assignments rather than parameter properties: this file is meant to // be copied into other codebases, and parameter properties need a full // TypeScript compile rather than plain type stripping. constructor(options: ClientOptions) { this.baseUrl = options.baseUrl ?? "https://letissier.ie"; this.fingerprint = options.fingerprint; } private async post(path: string, body: Record) { const response = await fetch(`${this.baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const json = (await response.json().catch(() => ({}))) as Record; if (!response.ok) { throw new Error(String(json.message ?? `Request failed (${response.status})`)); } return json; } activate(key: string, label?: string) { return this.post("/api/licence/activate", { key, machine: this.fingerprint, label, }) as Promise<{ token: string; checkInBy: string }>; } heartbeat(key: string) { return this.post("/api/licence/heartbeat", { key, machine: this.fingerprint }) as Promise<{ token: string; checkInBy: string; }>; } deactivate(key: string) { return this.post("/api/licence/deactivate", { key, machine: this.fingerprint }); } startTrial(product: string, email: string, name?: string) { return this.post("/api/licence/trial", { product, email, name, machine: this.fingerprint, }) as Promise<{ key: string; token: string; expiresAt: string }>; } }