From 2621f2cdbe25cc0018cfeccdfe39d47a903ffcad Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 8 May 2026 10:39:11 -0500 Subject: [PATCH] Add online license revocation check (Keysat /v1/validate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, a license revoked in the Keysat admin UI keeps unlocking the app on the customer's machine — Ed25519 signatures are perpetually valid, so the offline-only check never sees the revocation. What this adds: • license.js: validateOnline() calls licensing.keysat.xyz/v1/validate via @keysat/licensing-client's Client. Hard rejections (revoked, suspended, expired, not_found, product_mismatch, fingerprint_mismatch, too_many_machines, invalid_state) immediately flip state to "invalid" and persist the verdict to .state.json so it survives restarts. rate_limited and unknown reasons are treated as transient. • Network errors keep the prior state for up to MAX_OFFLINE_DAYS (default 7, env-overridable) since the last successful validate. Past the ceiling, lock out with reason=validation_overdue. This avoids breaking customers when Keysat is briefly down while still catching revocations on machines that go offline forever. • license.js: deactivate() helper that removes both license.txt and its sidecar state file (idempotent). publicView() now exposes lastValidatedAt, serverStatus, graceUntil for the UI. • index.js: refreshLicenseOnline() runs on startup (async, non- blocking), every 6h thereafter (env-overridable), and at activation time with an 8s timeout cap so a slow Keysat doesn't hang the activation UI. State changes are logged. • index.js: /api/license/activate now awaits an online confirmation after the offline signature check passes. A revoked key pasted into the activation modal fails fast instead of working until the next poll. --- server/index.js | 65 +++++++++++-- server/license.js | 237 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 290 insertions(+), 12 deletions(-) diff --git a/server/index.js b/server/index.js index 33c5918..380b97c 100644 --- a/server/index.js +++ b/server/index.js @@ -107,6 +107,42 @@ console.log( (LIC.reason ? ` reason=${LIC.reason}` : "") ); +// Periodic online validation against licensing.keysat.xyz. Catches +// revocations, suspensions, and expirations that happen after activation — +// without it, a revoked key keeps working until the customer reinstalls. +// +// On network errors we keep the prior state up to MAX_OFFLINE_DAYS (see +// server/license.js); past the ceiling we lock out. +const VALIDATE_INTERVAL_MS = parseInt( + process.env.YT_SUMMARIZER_VALIDATE_INTERVAL_MS || String(6 * 60 * 60 * 1000), + 10 +); +const ACTIVATE_VALIDATE_TIMEOUT_MS = 8000; + +async function refreshLicenseOnline(reason) { + const prev = LIC; + try { + LIC = await license.validateOnline(); + } catch (e) { + // validateOnline shouldn't throw, but be defensive. + console.error(`[license] refresh threw (${reason}):`, e?.message || e); + return; + } + if (LIC.state !== prev.state || LIC.reason !== prev.reason) { + console.log( + `[license] refresh (${reason}): state=${LIC.state}` + + (LIC.reason ? ` reason=${LIC.reason}` : "") + + ` entitlements=[${[...LIC.entitlements].join(",")}]` + ); + } +} + +// Async startup check — don't block the server from coming up. +refreshLicenseOnline("startup").catch(() => {}); +setInterval(() => { + refreshLicenseOnline("scheduled").catch(() => {}); +}, VALIDATE_INTERVAL_MS); + // Endpoints reachable without a license — kept intentionally minimal. const LICENSE_OPEN_PATHS = new Set([ "/api/health", @@ -186,7 +222,7 @@ app.get("/api/license-status", (_req, res) => { res.json(license.publicView(LIC)); }); -app.post("/api/license/activate", (req, res) => { +app.post("/api/license/activate", async (req, res) => { try { LIC = license.activate(req.body && req.body.license_key); } catch (e) { @@ -198,21 +234,36 @@ app.post("/api/license/activate", (req, res) => { } return res.status(500).json({ error: "activation_failed", message: e?.message }); } + if (LIC.state !== "licensed") { + // Offline signature check failed — no point hitting the server. + return res.status(400).json({ + ok: false, + error: LIC.reason || "invalid", + ...license.publicView(LIC), + }); + } + + // Offline check passed. Confirm with the licensing server so a key that + // was revoked before activation gets rejected immediately. Cap the wait + // so a slow server doesn't hang the activation UI — if we time out, + // accept the offline-verified state and let the periodic poll catch up. + await Promise.race([ + refreshLicenseOnline("activation"), + new Promise((resolve) => setTimeout(resolve, ACTIVATE_VALIDATE_TIMEOUT_MS)), + ]); + if (LIC.state === "licensed") { return res.json({ ok: true, ...license.publicView(LIC) }); } return res.status(400).json({ ok: false, - error: "invalid", + error: LIC.reason || "invalid", ...license.publicView(LIC), }); }); -app.post("/api/license/deactivate", async (_req, res) => { - try { - await fs.unlink(license.LICENSE_PATH).catch(() => {}); - } catch {} - LIC = license.checkLicense(); +app.post("/api/license/deactivate", (_req, res) => { + LIC = license.deactivate(); res.json({ ok: true, ...license.publicView(LIC) }); }); diff --git a/server/license.js b/server/license.js index 473449d..f81170c 100644 --- a/server/license.js +++ b/server/license.js @@ -24,7 +24,7 @@ import fs from "fs"; import path from "path"; -import { Verifier, PublicKey } from "@keysat/licensing-client"; +import { Verifier, PublicKey, Client } from "@keysat/licensing-client"; export const PRODUCT_SLUG = "youtube-summarizer"; export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz"; @@ -40,6 +40,21 @@ export const LICENSE_PATH = process.env.YT_SUMMARIZER_LICENSE_KEY_PATH || path.join(DATA_DIR, "license.txt"); +// Grace ceiling for network errors. As long as we successfully validated +// against Keysat within this window, we keep the license live even if +// subsequent online checks fail (Keysat down, customer offline, etc.). +// Past the ceiling, we lock out — otherwise a revoked key on a permanently +// offline machine would never get caught. +const MAX_OFFLINE_DAYS = parseInt( + process.env.YT_SUMMARIZER_MAX_OFFLINE_DAYS || "7", + 10 +); +const MAX_OFFLINE_MS = MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000; + +// Sidecar file that tracks last successful online validation. Lets the +// grace window survive restarts. +const STATE_PATH = LICENSE_PATH + ".state.json"; + // ── Verifier instance (built once at module load) ───────────────────────── let verifier = null; let verifierError = null; @@ -50,6 +65,13 @@ try { console.error(`[license] failed to parse embedded public key: ${verifierError}`); } +// Lazy HTTP client for online validation against the licensing service. +let onlineClient = null; +function getOnlineClient() { + if (!onlineClient) onlineClient = new Client(KEYSAT_BASE_URL); + return onlineClient; +} + // ── Helpers ─────────────────────────────────────────────────────────────── function readLicenseString() { const fromEnv = (process.env.YT_SUMMARIZER_LICENSE_KEY || "").trim(); @@ -62,6 +84,30 @@ function readLicenseString() { } } +function readPersistedState() { + try { + return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); + } catch { + return null; + } +} + +function writePersistedState(obj) { + try { + const tmp = STATE_PATH + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, STATE_PATH); + } catch (e) { + console.error(`[license] failed to write state: ${e?.message || e}`); + } +} + +function clearPersistedState() { + try { + fs.unlinkSync(STATE_PATH); + } catch {} +} + function emptyState(extra = {}) { return { state: "unlicensed", @@ -70,14 +116,35 @@ function emptyState(extra = {}) { entitlements: new Set(), expiresAt: null, isTrial: false, + lastValidatedAt: null, + serverStatus: null, + graceUntil: null, ...extra, }; } +// Definitive server reasons that should immediately invalidate the license. +// rate_limited is intentionally NOT here — that's a transient server-side +// throttle, not a verdict on the key. +const HARD_REJECTIONS = new Set([ + "bad_format", + "bad_signature", + "not_found", + "revoked", + "suspended", + "expired", + "product_mismatch", + "fingerprint_mismatch", + "too_many_machines", + "invalid_state", +]); + // ── Public API ──────────────────────────────────────────────────────────── // // checkLicense() — read + verify; returns a frozen-ish state object. -// Callers can re-invoke after activation to refresh. +// Callers can re-invoke after activation to refresh. This is offline-only: +// it verifies the Ed25519 signature and layers on any persisted online- +// validation state. The hard online check happens in validateOnline(). export function checkLicense() { if (verifierError) { return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` }); @@ -85,6 +152,7 @@ export function checkLicense() { const raw = readLicenseString(); if (!raw) return emptyState(); + let base; try { const ok = verifier.verify(raw); const payload = ok.payload || {}; @@ -92,22 +160,47 @@ export function checkLicense() { if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) { return emptyState({ state: "invalid", reason: "product_mismatch" }); } - return { + base = emptyState({ state: "licensed", - reason: null, licenseId: payload.licenseId || null, entitlements: new Set(payload.entitlements || []), expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null, isTrial: !!(payload.flags & 1), - }; + }); } catch (e) { return emptyState({ state: "invalid", reason: e?.message || "verify_failed" }); } + + // Layer on persisted online state. If a previous online check found this + // key revoked/suspended/etc., honor that until a successful re-check. + const persisted = readPersistedState(); + if (persisted) { + if (persisted.lastValidatedAt) { + base.lastValidatedAt = new Date(persisted.lastValidatedAt); + } + if (persisted.serverStatus) base.serverStatus = persisted.serverStatus; + if (persisted.graceUntil) base.graceUntil = new Date(persisted.graceUntil); + if (persisted.lastResult && persisted.lastResult !== "ok") { + // The last conclusive online check rejected this key. Stay invalid + // until a successful re-check overwrites the sidecar. + return emptyState({ + state: "invalid", + reason: persisted.lastResult, + licenseId: base.licenseId, + lastValidatedAt: base.lastValidatedAt, + serverStatus: base.serverStatus, + }); + } + } + return base; } // activate(rawKey) — write a pasted key to disk, then re-check. // Returns the new license state. Throws on bad input format only; // signature failures surface as state: 'invalid' with a reason. +// +// Clears any persisted online-validation state — a new key gets a fresh +// online check, untainted by what an old key was last told. export function activate(rawKey) { const key = (rawKey || "").trim(); if (!key.startsWith("LIC1-")) { @@ -120,9 +213,138 @@ export function activate(rawKey) { fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true }); fs.writeFileSync(tmp, key + "\n", { mode: 0o600 }); fs.renameSync(tmp, LICENSE_PATH); + clearPersistedState(); return checkLicense(); } +// deactivate() — remove the on-disk license + persisted online state. +// Idempotent. Returns the new (empty) state. +export function deactivate() { + try { + fs.unlinkSync(LICENSE_PATH); + } catch {} + clearPersistedState(); + return checkLicense(); +} + +// validateOnline() — call licensing.keysat.xyz/v1/validate, merge the +// response into a fresh state object, and persist last-validated info. +// +// Behavior: +// • Server says ok=true → state stays/becomes "licensed", entitlements +// and expiry refreshed from server. +// • Server says ok=false with a hard reason (revoked, suspended, expired, +// not_found, product_mismatch, fingerprint_mismatch, too_many_machines) +// → state becomes "invalid"; persisted so +// checkLicense() keeps rejecting after restart. +// • Server says rate_limited → treated as a transient error; state kept. +// • Network error / timeout → state kept up to MAX_OFFLINE_DAYS since the +// last successful validate. Past the ceiling, +// state becomes "invalid" with reason +// "validation_overdue". +// +// Always returns a state object — never throws. +export async function validateOnline() { + const local = checkLicense(); + // No key, or local sig failure — no point asking the server. + if (local.state !== "licensed" && local.state !== "invalid") return local; + const raw = readLicenseString(); + if (!raw) return local; + + let resp; + try { + resp = await getOnlineClient().validate(raw, { productSlug: PRODUCT_SLUG }); + } catch (e) { + return applyNetworkErrorGrace(local, e?.code || e?.message || "network_error"); + } + + const now = new Date(); + + if (resp.ok) { + const next = emptyState({ + state: "licensed", + licenseId: resp.licenseId || local.licenseId, + entitlements: new Set(resp.entitlements || [...local.entitlements]), + expiresAt: resp.expiresAt ? new Date(resp.expiresAt) : local.expiresAt, + isTrial: resp.isTrial != null ? !!resp.isTrial : local.isTrial, + lastValidatedAt: now, + serverStatus: resp.status || "active", + graceUntil: resp.graceUntil ? new Date(resp.graceUntil) : null, + }); + writePersistedState({ + lastValidatedAt: now.toISOString(), + serverStatus: next.serverStatus, + lastResult: "ok", + graceUntil: next.graceUntil ? next.graceUntil.toISOString() : null, + }); + return next; + } + + const reason = resp.reason || "rejected"; + + if (reason === "rate_limited") { + // Transient. Don't change state. + return applyNetworkErrorGrace(local, "rate_limited"); + } + + if (HARD_REJECTIONS.has(reason)) { + console.warn(`[license] online validation rejected: ${reason}`); + writePersistedState({ + lastValidatedAt: now.toISOString(), + serverStatus: resp.status || reason, + lastResult: reason, + }); + return emptyState({ + state: "invalid", + reason, + licenseId: local.licenseId, + lastValidatedAt: now, + serverStatus: resp.status || reason, + }); + } + + // Unknown reason — be conservative, treat as transient so we don't lock + // out paying users on an SDK/server version mismatch. + console.warn(`[license] online validation returned unknown reason: ${reason}`); + return applyNetworkErrorGrace(local, reason); +} + +function applyNetworkErrorGrace(local, errorReason) { + const persisted = readPersistedState(); + const lastValidatedAt = persisted?.lastValidatedAt + ? new Date(persisted.lastValidatedAt) + : null; + + if (!lastValidatedAt) { + // Never successfully validated online. Allow offline state but flag it + // — the periodic poller will keep trying. + console.warn( + `[license] online validation unavailable (${errorReason}); no prior successful check yet` + ); + return { ...local, lastValidatedAt: null }; + } + + const ageMs = Date.now() - lastValidatedAt.getTime(); + if (ageMs > MAX_OFFLINE_MS) { + const ageDays = (ageMs / 86400000).toFixed(1); + console.warn( + `[license] online validation overdue: ${ageDays}d since last successful check (max ${MAX_OFFLINE_DAYS}d). Locking out.` + ); + return emptyState({ + state: "invalid", + reason: "validation_overdue", + licenseId: local.licenseId, + lastValidatedAt, + serverStatus: persisted?.serverStatus || null, + }); + } + + console.warn( + `[license] online validation skipped (${errorReason}); within ${MAX_OFFLINE_DAYS}d grace` + ); + return { ...local, lastValidatedAt }; +} + // publicView(state) — safe shape for /api/license-status responses. // Never leaks the raw license key (it's a bearer credential). export function publicView(state) { @@ -136,6 +358,11 @@ export function publicView(state) { productSlug: PRODUCT_SLUG, keysatBaseUrl: KEYSAT_BASE_URL, licensePath: LICENSE_PATH, + lastValidatedAt: state.lastValidatedAt + ? state.lastValidatedAt.toISOString() + : null, + serverStatus: state.serverStatus || null, + graceUntil: state.graceUntil ? state.graceUntil.toISOString() : null, }; }