// src/verify.ts import * as ed from "@noble/ed25519"; import { sha512 } from "@noble/hashes/sha512"; // src/errors.ts var LicensingError = class extends Error { /** * Machine-readable reason code. Common values: * `"bad_format"`, `"bad_encoding"`, `"bad_version"`, `"bad_signature"`, * `"expired"`, `"server_error"`, `"http_error"`, `"other"`. */ code; constructor(code, message) { super(message); this.name = "LicensingError"; this.code = code; } }; // src/base32.ts var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; var DECODE_TABLE = (() => { const t = {}; for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET[i]] = i; return t; })(); function decodeBase32NoPad(input) { const up = input.toUpperCase(); const out = new Uint8Array(Math.floor(up.length * 5 / 8)); let bits = 0; let value = 0; let outPos = 0; for (let i = 0; i < up.length; i++) { const ch = up[i]; const v = DECODE_TABLE[ch]; if (v === void 0) { throw new LicensingError("bad_encoding", `invalid base32 character '${ch}'`); } value = value << 5 | v; bits += 5; if (bits >= 8) { bits -= 8; out[outPos++] = value >> bits & 255; } } return out.subarray(0, outPos); } // src/key.ts var KEY_PREFIX = "LIC1"; var KEY_VERSION_V1 = 1; var KEY_VERSION_V2 = 2; var KEY_VERSION = KEY_VERSION_V2; var FLAG_FINGERPRINT_BOUND = 1; var FLAG_TRIAL = 2; var PAYLOAD_V1_LEN = 74; var PAYLOAD_V2_HEAD_LEN = 83; var SIGNATURE_LEN = 64; function isExpiredAt(payload, nowUnixSeconds) { return payload.expiresAt !== 0 && nowUnixSeconds >= payload.expiresAt; } function hasEntitlement(payload, slug) { return payload.entitlements.includes(slug); } function parseLicenseKey(raw) { const trimmed = raw.trim(); const firstDash = trimmed.indexOf("-"); if (firstDash < 0) throw new LicensingError("bad_format", "key is missing prefix delimiter"); const prefix = trimmed.slice(0, firstDash); if (prefix !== KEY_PREFIX) throw new LicensingError("bad_format", `unknown key prefix '${prefix}'`); const body = trimmed.slice(firstDash + 1); const lastDash = body.lastIndexOf("-"); if (lastDash < 0) throw new LicensingError("bad_format", "key is missing signature delimiter"); const payloadB32 = body.slice(0, lastDash); const signatureB32 = body.slice(lastDash + 1); const payloadBytes = decodeBase32NoPad(payloadB32); const signature = decodeBase32NoPad(signatureB32); if (signature.length !== SIGNATURE_LEN) { throw new LicensingError( "bad_format", `signature is ${signature.length} bytes; expected ${SIGNATURE_LEN}` ); } if (payloadBytes.length < 1) { throw new LicensingError("bad_format", "empty payload"); } const version = payloadBytes[0]; let payload; switch (version) { case KEY_VERSION_V1: payload = parseV1(payloadBytes); break; case KEY_VERSION_V2: payload = parseV2(payloadBytes); break; default: throw new LicensingError("bad_version", `unsupported key version ${version}`); } return { payload, signedBytes: payloadBytes, signature }; } function parseV1(payloadBytes) { if (payloadBytes.length !== PAYLOAD_V1_LEN) { throw new LicensingError( "bad_format", `v1 payload is ${payloadBytes.length} bytes; expected ${PAYLOAD_V1_LEN}` ); } const flags = payloadBytes[1]; const productId = payloadBytes.slice(2, 18); const licenseId = payloadBytes.slice(18, 34); const issuedAt = readBigEndianI64(payloadBytes, 34); const fingerprintHash = payloadBytes.slice(42, 74); return { version: KEY_VERSION_V1, flags, productId, licenseId, issuedAt, expiresAt: 0, fingerprintHash, entitlements: [], productUuid: uuidString(productId), licenseUuid: uuidString(licenseId), isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0, isTrial: (flags & FLAG_TRIAL) !== 0 }; } function parseV2(payloadBytes) { if (payloadBytes.length < PAYLOAD_V2_HEAD_LEN) { throw new LicensingError( "bad_format", `v2 payload is ${payloadBytes.length} bytes; expected >= ${PAYLOAD_V2_HEAD_LEN}` ); } const flags = payloadBytes[1]; const productId = payloadBytes.slice(2, 18); const licenseId = payloadBytes.slice(18, 34); const issuedAt = readBigEndianI64(payloadBytes, 34); const expiresAt = readBigEndianI64(payloadBytes, 42); const fingerprintHash = payloadBytes.slice(50, 82); const numEntitlements = payloadBytes[82]; const entitlements = []; let cursor = PAYLOAD_V2_HEAD_LEN; const decoder = new TextDecoder("utf-8", { fatal: true }); for (let i = 0; i < numEntitlements; i++) { if (cursor >= payloadBytes.length) { throw new LicensingError("bad_format", "truncated entitlement list"); } const len = payloadBytes[cursor]; cursor += 1; if (cursor + len > payloadBytes.length) { throw new LicensingError("bad_format", "truncated entitlement"); } try { entitlements.push(decoder.decode(payloadBytes.slice(cursor, cursor + len))); } catch { throw new LicensingError("bad_format", "entitlement not utf-8"); } cursor += len; } if (cursor !== payloadBytes.length) { throw new LicensingError("bad_format", "trailing bytes in payload"); } return { version: KEY_VERSION_V2, flags, productId, licenseId, issuedAt, expiresAt, fingerprintHash, entitlements, productUuid: uuidString(productId), licenseUuid: uuidString(licenseId), isFingerprintBound: (flags & FLAG_FINGERPRINT_BOUND) !== 0, isTrial: (flags & FLAG_TRIAL) !== 0 }; } function readBigEndianI64(buf, offset) { const view = new DataView(buf.buffer, buf.byteOffset + offset, 8); const hi = view.getInt32(0, false); const lo = view.getUint32(4, false); return hi * 2 ** 32 + lo; } function uuidString(b) { const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; } // src/fingerprint.ts import { sha256 } from "@noble/hashes/sha256"; function hashFingerprint(raw) { return sha256(new TextEncoder().encode(raw)); } // src/verify.ts ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); var Verifier = class { pubkey; constructor(pubkey) { this.pubkey = pubkey; } /** Verify a license key string. Throws on any failure. */ verify(keyStr) { const key = parseLicenseKey(keyStr); const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw); if (!ok) throw new LicensingError("bad_signature", "signature did not verify"); return { payload: key.payload, licenseId: key.payload.licenseUuid, productId: key.payload.productUuid }; } /** * Verify AND enforce that, if the key is fingerprint-bound, the given * fingerprint matches. If the key is not bound, the fingerprint is * ignored. Throws on any failure. */ verifyWithFingerprint(keyStr, fingerprint) { const result = this.verify(keyStr); if (result.payload.isFingerprintBound) { const expected = hashFingerprint(fingerprint); const stored = result.payload.fingerprintHash; if (!equalBytes(expected, stored)) { throw new LicensingError("bad_signature", "fingerprint does not match bound key"); } } return result; } /** * Verify a key and additionally reject it with an `expired` error if * `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys * (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is * offline-only — no grace window logic; use `Client.validate` for that. */ verifyWithTime(keyStr, nowUnixSeconds) { const result = this.verify(keyStr); if (isExpiredAt(result.payload, nowUnixSeconds)) { throw new LicensingError("expired", "license has expired"); } return result; } }; function equalBytes(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } // src/online.ts var Client = class { base; constructor(baseUrl) { this.base = baseUrl.replace(/\/+$/, ""); } /** The normalized base URL this client is pinned to. */ baseUrl() { return this.base; } /** Fetch the server's PEM-encoded public key. */ async fetchPubkeyPem() { const data = await this.get("/v1/pubkey"); return data.public_key_pem; } /** * Server-authoritative validation. Returns the full response including * expiry / entitlements / seat fields introduced in v2. * * Two-argument form kept for call-site compatibility with earlier SDK * versions; pass an options object for the full set of fields. */ async validate(key, productSlugOrOptions, fingerprint) { const opts = typeof productSlugOrOptions === "string" ? { productSlug: productSlugOrOptions, fingerprint } : productSlugOrOptions ?? {}; const raw = await this.post("/v1/validate", { key, product_slug: opts.productSlug, fingerprint: opts.fingerprint, hostname: opts.hostname, platform: opts.platform }); return this.toValidateResponse(raw); } /** Lightweight heartbeat. Server updates `last_heartbeat_at`. */ async heartbeat(key, fingerprint) { const raw = await this.post("/v1/machines/heartbeat", { key, fingerprint }); return this.toMachineResponse(raw); } /** Explicitly activate a seat for the given fingerprint. */ async activate(key, fingerprint, opts = {}) { const raw = await this.post("/v1/machines/activate", { key, fingerprint, hostname: opts.hostname, platform: opts.platform }); return this.toMachineResponse(raw); } /** Free a seat held by the given fingerprint. */ async deactivate(key, fingerprint, reason) { const raw = await this.post("/v1/machines/deactivate", { key, fingerprint, reason }); return this.toMachineResponse(raw); } /** Start a purchase. Returns the checkout URL and invoice id. */ async startPurchase(productSlug, opts = {}) { const raw = await this.post("/v1/purchase", { product: productSlug, buyer_email: opts.buyerEmail, buyer_note: opts.buyerNote, redirect_url: opts.redirectUrl, code: opts.code, policy_slug: opts.policySlug }); return { invoiceId: raw.invoice_id, btcpayInvoiceId: raw.btcpay_invoice_id, checkoutUrl: raw.checkout_url, amountSats: raw.amount_sats, pollUrl: raw.poll_url }; } /** * List public, buyer-visible policies (tiers) for a product. No * auth — same data the licensing service's `/buy/` page * uses server-side. Use this to render an in-app tier picker * that stays in sync with the operator's admin-side tier setup. * * Returns each policy's slug, display name, price (in the * product's listed currency's smallest unit — sats or cents), * entitlements, recurring/trial flags. Internal fields (id, * tip recipients, raw metadata) are deliberately omitted. */ async listPublicPolicies(productSlug) { const raw = await this.get( `/v1/products/${encodeURIComponent(productSlug)}/policies` ); const product = raw.product; const policies = raw.policies ?? []; return { product: { slug: product.slug, name: product.name, description: product.description ?? "", basePriceSats: product.base_price_sats }, policies: policies.map((p) => ({ slug: p.slug, name: p.name, description: p.description ?? "", priceSats: p.price_sats, durationSeconds: p.duration_seconds ?? 0, maxMachines: p.max_machines ?? 1, isTrial: !!p.is_trial, entitlements: p.entitlements ?? [], highlighted: !!p.highlighted, isRecurring: !!p.is_recurring, renewalPeriodDays: p.renewal_period_days ?? 0, trialDays: p.trial_days ?? 0 })) }; } /** * Redeem a `free_license` code: bypass BTCPay entirely and receive the * signed license key directly. Throws if the code is unknown / disabled * / expired / wrong product / not a free_license code, or if the cap * has been reached. */ async redeemFreeLicense(productSlug, code, opts = {}) { const raw = await this.post("/v1/redeem", { product: productSlug, code, buyer_email: opts.buyerEmail, buyer_note: opts.buyerNote }); return { licenseId: raw.license_id, licenseKey: raw.license_key, invoiceId: raw.invoice_id, redemptionId: raw.redemption_id }; } /** Poll a purchase by its invoice id. */ async pollPurchase(invoiceId) { const raw = await this.get( `/v1/purchase/${encodeURIComponent(invoiceId)}` ); return { invoiceId: raw.invoice_id, status: raw.status, productId: raw.product_id, amountSats: raw.amount_sats, licenseKey: raw.license_key ?? void 0, licenseId: raw.license_id ?? void 0 }; } /** * Convenience: open the checkout, poll until a license key is issued, * then return it. Suitable for CLI usage or for an app UI that shows a * spinner while the buyer pays. */ async waitForLicense(invoiceId, options = {}) { const interval = options.intervalMs ?? 5e3; const deadline = options.timeoutMs ? Date.now() + options.timeoutMs : Infinity; while (true) { const poll = await this.pollPurchase(invoiceId); if (poll.licenseKey) return poll.licenseKey; if (poll.status === "expired" || poll.status === "invalid") { throw new LicensingError("server_error", `invoice ended in status ${poll.status}`); } if (Date.now() > deadline) { throw new LicensingError("server_error", "timed out waiting for license issuance"); } await sleep(interval); } } // --- internals --- toValidateResponse(raw) { const entitlements = Array.isArray(raw.entitlements) ? raw.entitlements.filter((x) => typeof x === "string") : void 0; return { ok: !!raw.ok, reason: raw.reason, licenseId: raw.license_id, productId: raw.product_id, productSlug: raw.product_slug, issuedAt: raw.issued_at, expiresAt: raw.expires_at, graceUntil: raw.grace_until, inGracePeriod: raw.in_grace_period, isTrial: raw.is_trial, entitlements, status: raw.status, machineId: raw.machine_id, maxMachines: raw.max_machines }; } toMachineResponse(raw) { return { ok: !!raw.ok, reason: raw.reason, machineId: raw.machine_id, activeCount: raw.active_count, maxMachines: raw.max_machines }; } async get(path) { return this.request(path, { method: "GET" }); } async post(path, body) { return this.request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); } async request(path, init) { let resp; try { resp = await fetch(`${this.base}${path}`, init); } catch (e) { throw new LicensingError("http_error", e instanceof Error ? e.message : String(e)); } const text = await resp.text(); if (!resp.ok) { throw new LicensingError("server_error", `HTTP ${resp.status}: ${text}`); } try { return JSON.parse(text); } catch { throw new LicensingError("server_error", `non-JSON response: ${text}`); } } }; function sleep(ms) { return new Promise((res) => setTimeout(res, ms)); } // src/pubkey.ts var PublicKey = class _PublicKey { /** Raw 32-byte Ed25519 public key material. */ raw; constructor(raw) { if (raw.length !== 32) { throw new LicensingError( "bad_format", `public key must be 32 bytes; got ${raw.length}` ); } this.raw = raw; } /** Parse a PEM blob as emitted by the service. */ static fromPem(pem) { const stripped = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); if (!stripped) { throw new LicensingError("bad_format", "empty PEM input"); } const der = base64Decode(stripped); if (der.length < 32) { throw new LicensingError("bad_format", "PEM body too short to contain a public key"); } const raw = der.slice(der.length - 32); return new _PublicKey(raw); } /** Construct from raw bytes (no PEM envelope). */ static fromBytes(bytes) { return new _PublicKey(bytes); } }; function base64Decode(b64) { const nodeBuffer = globalThis.Buffer; if (nodeBuffer) { return new Uint8Array(nodeBuffer.from(b64, "base64")); } const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } export { Client, FLAG_FINGERPRINT_BOUND, FLAG_TRIAL, KEY_PREFIX, KEY_VERSION, KEY_VERSION_V1, KEY_VERSION_V2, LicensingError, PublicKey, Verifier, hasEntitlement, hashFingerprint, isExpiredAt, parseLicenseKey };