From b9cea3e91202af3a236faecdbdd75fd716dad20f Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Fri, 24 Apr 2026 07:34:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(licensing):=20v2.6=20Z2=20=E2=80=94=20Ed25?= =?UTF-8?q?519=20verify=20+=20PUBLIC=5FKEYS=20+=20entitlement=20cache=20(#?= =?UTF-8?q?341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(licensing): add server/licensing module with Ed25519 verify + entitlement cache - Add paseto-ts dependency (WebCrypto Ed25519, works on all 7 deploy targets) - server/licensing/public-keys.ts: PUBLIC_KEYS array with DEV placeholder PASERK key - server/licensing/verify.ts: verifyCertificate() iterates PUBLIC_KEYS, validates signature, expiry and instance_id — returns null (never throws) on invalid certs - server/licensing/entitlement.ts: loadEntitlement() with 60s in-process memoization; invalidateEntitlementCache() for post-refresh invalidation - Update LicenseEntitlement.expires_at / issued_at to string (ISO-8601 wire format) - Unit tests: valid cert, invalid sig, expired, wrong instance_id, key rotation Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * fix(licensing): apply biome lint fixes (import order, template literal) Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f --------- Co-authored-by: Bob --- package-lock.json | 103 +++++++++++++++++++++++++++ package.json | 1 + server/licensing/entitlement.ts | 50 +++++++++++++ server/licensing/public-keys.test.ts | 16 +++++ server/licensing/public-keys.ts | 10 +++ server/licensing/verify.test.ts | 80 +++++++++++++++++++++ server/licensing/verify.ts | 44 ++++++++++++ shared/types/licensing.ts | 4 +- 8 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 server/licensing/entitlement.ts create mode 100644 server/licensing/public-keys.test.ts create mode 100644 server/licensing/public-keys.ts create mode 100644 server/licensing/verify.test.ts create mode 100644 server/licensing/verify.ts diff --git a/package-lock.json b/package-lock.json index 1ee4bf90..060d3d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "nanoid": "^5.1.0", "next-themes": "^0.4.6", "nodemailer": "^8.0.5", + "paseto-ts": "^2.0.6", "pdfjs-dist": "^5.6.205", "radix-ui": "^1.4.3", "react": "^19.0.0", @@ -8783,6 +8784,97 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@stablelib/binary": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-2.0.1.tgz", + "integrity": "sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w==", + "license": "MIT", + "dependencies": { + "@stablelib/int": "^2.0.1" + } + }, + "node_modules/@stablelib/blake2b": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/blake2b/-/blake2b-2.0.1.tgz", + "integrity": "sha512-kBN4i9FHpkTEVrHwKtb1ZjI6QwyoK8emY9TaLcHHFSZkw2FN9Td8Js9mU2U10q+EZ3Mp6z78QgdPDwJXbc3Dtw==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^2.0.1", + "@stablelib/hash": "^2.0.0", + "@stablelib/wipe": "^2.0.1" + } + }, + "node_modules/@stablelib/chacha": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/chacha/-/chacha-2.0.1.tgz", + "integrity": "sha512-lS1FqtNqofxe2vLkRsLli2m3x/XanUyAYRphLhdHumKeIsLbjbCXdCq3Pf/eWiO7G3QlSG5ViqnoVjktzfLWMg==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^2.0.1", + "@stablelib/wipe": "^2.0.1" + } + }, + "node_modules/@stablelib/ed25519": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@stablelib/ed25519/-/ed25519-2.1.0.tgz", + "integrity": "sha512-8GLWoJur9nJiErABKHs5MjceSiYJJ2n8QP9k8Q5x6GWU2y5oAQ6qzPh8kCgQy5aHlUxcmRA1frQ5Gu2bHoIDPw==", + "license": "MIT", + "dependencies": { + "@stablelib/random": "^2.0.1", + "@stablelib/sha512": "^2.0.1", + "@stablelib/wipe": "^2.0.1" + } + }, + "node_modules/@stablelib/hash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-2.0.0.tgz", + "integrity": "sha512-u3WPSqGido8lwJuMcrBgM5K54LrPGhkWAdtsyccf7dGsLixAZUds77zOAbu7bvKPwQlmoByH0txBi5rTmEKuHg==", + "license": "MIT" + }, + "node_modules/@stablelib/int": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-2.0.1.tgz", + "integrity": "sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww==", + "license": "MIT" + }, + "node_modules/@stablelib/random": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-2.0.1.tgz", + "integrity": "sha512-W6GAtXEEs7r+dSbuBsvoFmlyL3gLxle41tQkjKu17dDWtDdjhVUbtRfRCQcCUeczwkgjQxMPopgwYEvxXtHXGw==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^2.0.1", + "@stablelib/wipe": "^2.0.1" + } + }, + "node_modules/@stablelib/sha512": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/sha512/-/sha512-2.0.1.tgz", + "integrity": "sha512-DUNe5cbnoH3sSIN+MG04RvTCLXtkbyy/SnQxiNO+GgF/KSXkkUSlF6mUVvCUdZBZ2X3NgogR+tAvaRSn8wxnLw==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^2.0.1", + "@stablelib/hash": "^2.0.0", + "@stablelib/wipe": "^2.0.1" + } + }, + "node_modules/@stablelib/wipe": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-2.0.1.tgz", + "integrity": "sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg==", + "license": "MIT" + }, + "node_modules/@stablelib/xchacha20": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/xchacha20/-/xchacha20-2.0.1.tgz", + "integrity": "sha512-k55pNv7gIM4mUPU00+nJYTxKiUVNwAtsgrridC0aIU5cVbw9u6qP99x8ENu5eiwOEhZUNg+p3tTOLooCeAOJQA==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^2.0.1", + "@stablelib/chacha": "^2.0.1", + "@stablelib/wipe": "^2.0.1" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -14434,6 +14526,17 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/paseto-ts": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/paseto-ts/-/paseto-ts-2.0.6.tgz", + "integrity": "sha512-Ul3AzoCcS4uA6jF+GW2zYUxZY78xsqCbfxdv2Khk/30hDxAzcvsWQjp4uW1XcbkrO9zNrPbdBv4HiXgJigWLVA==", + "license": "MIT", + "dependencies": { + "@stablelib/blake2b": "^2.0.1", + "@stablelib/ed25519": "^2.1.0", + "@stablelib/xchacha20": "^2.0.1" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", diff --git a/package.json b/package.json index 8c85f213..586308b3 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "nanoid": "^5.1.0", "next-themes": "^0.4.6", "nodemailer": "^8.0.5", + "paseto-ts": "^2.0.6", "pdfjs-dist": "^5.6.205", "radix-ui": "^1.4.3", "react": "^19.0.0", diff --git a/server/licensing/entitlement.ts b/server/licensing/entitlement.ts new file mode 100644 index 00000000..0e6e79bd --- /dev/null +++ b/server/licensing/entitlement.ts @@ -0,0 +1,50 @@ +import type { ProFeature } from '@shared/types' +import { eq } from 'drizzle-orm' +import { licenseBinding } from '../db/schema' +import type { Database } from '../platform/interface' +import { verifyCertificate } from './verify' + +export interface EntitlementSummary { + plan: 'community' | 'pro' + features: ProFeature[] +} + +const CACHE_TTL_MS = 60_000 + +let cachedSummary: EntitlementSummary | null = null +let cachedAt = 0 + +// Load and verify the cached license cert from the database. +// Result is memoized in-process for 60 seconds — feature checks never block on DB. +// Cache is automatically invalidated on restart or after TTL. +export async function loadEntitlement(db: Database): Promise { + const now = Date.now() + if (cachedAt > 0 && now - cachedAt < CACHE_TTL_MS) { + return cachedSummary + } + + const rows = await db + .select({ instanceId: licenseBinding.instanceId, cachedCert: licenseBinding.cachedCert }) + .from(licenseBinding) + .where(eq(licenseBinding.id, 1)) + .limit(1) + + const row = rows[0] + if (!row?.cachedCert) { + cachedSummary = null + cachedAt = now + return null + } + + const entitlement = verifyCertificate(row.cachedCert, row.instanceId) + cachedSummary = entitlement ? { plan: entitlement.plan, features: entitlement.features } : null + cachedAt = now + return cachedSummary +} + +// Invalidate the in-process cache — call after a cert refresh so the next +// feature check picks up the new entitlement without waiting for TTL. +export function invalidateEntitlementCache(): void { + cachedAt = 0 + cachedSummary = null +} diff --git a/server/licensing/public-keys.test.ts b/server/licensing/public-keys.test.ts new file mode 100644 index 00000000..31f81f1a --- /dev/null +++ b/server/licensing/public-keys.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { PUBLIC_KEYS } from './public-keys' + +describe('PUBLIC_KEYS', () => { + it('exports a non-empty array', () => { + expect(PUBLIC_KEYS).toBeInstanceOf(Array) + expect(PUBLIC_KEYS.length).toBeGreaterThan(0) + }) + + it('each entry is a PASERK v4 public key', () => { + for (const key of PUBLIC_KEYS) { + expect(key).toMatch(/^k4\.public\./) + } + }) +}) diff --git a/server/licensing/public-keys.ts b/server/licensing/public-keys.ts new file mode 100644 index 00000000..73842775 --- /dev/null +++ b/server/licensing/public-keys.ts @@ -0,0 +1,10 @@ +// Replace DEV key with production key from cloud.zpan.space before Z11 +// +// Rotation: add new key to the array; old certs signed by any key in the list +// will continue to verify. Remove a key only after all certs signed by it have +// expired or been re-issued. +// +// DEV placeholder keypair (throwaway — real production key lands via a +// cross-repo PR from cloud's C5 task): +// secret: k4.secret.K_XrtRH8ozh6oM38rkCz7oHxU_GbKIuExCg2jmBl9_VgfF29_7kGkFAnXvII1bHUBy2Yjw04DRdC4kmbuSND2Q +export const PUBLIC_KEYS: string[] = ['k4.public.YHxdvf-5BpBQJ17yCNWx1ActmI8NOA0XQuJJm7kjQ9k'] diff --git a/server/licensing/verify.test.ts b/server/licensing/verify.test.ts new file mode 100644 index 00000000..0527bb29 --- /dev/null +++ b/server/licensing/verify.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node +import { generateKeys, sign } from 'paseto-ts/v4' +import { describe, expect, it } from 'vitest' +import { verifyCertificate } from './verify' + +// DEV keypair matching PUBLIC_KEYS[0] — used to sign test certs +const DEV_SECRET = 'k4.secret.K_XrtRH8ozh6oM38rkCz7oHxU_GbKIuExCg2jmBl9_VgfF29_7kGkFAnXvII1bHUBy2Yjw04DRdC4kmbuSND2Q' + +function futureIso(offsetMs: number): string { + return new Date(Date.now() + offsetMs).toISOString() +} + +function pastIso(offsetMs: number): string { + return new Date(Date.now() - offsetMs).toISOString() +} + +function signCert(overrides: Record = {}, key = DEV_SECRET): string { + return sign(key, { + account_id: 'acct-1', + instance_id: 'inst-abc', + plan: 'pro', + features: ['white_label'], + issued_at: new Date().toISOString(), + expires_at: futureIso(3_600_000), // 1 hour from now + ...overrides, + }) +} + +describe('verifyCertificate', () => { + it('returns entitlement for a valid cert signed by PUBLIC_KEYS[0]', () => { + const cert = signCert() + const result = verifyCertificate(cert, 'inst-abc') + + expect(result).not.toBeNull() + expect(result?.plan).toBe('pro') + expect(result?.features).toEqual(['white_label']) + expect(result?.instance_id).toBe('inst-abc') + expect(result?.account_id).toBe('acct-1') + }) + + it('returns null for a cert with an invalid signature', () => { + const cert = signCert() + // Corrupt the cert by altering a character in the payload segment + const corrupted = `${cert.slice(0, -5)}XXXXX` + expect(verifyCertificate(corrupted, 'inst-abc')).toBeNull() + }) + + it('returns null for an expired cert', () => { + const cert = signCert({ expires_at: pastIso(1000) }) + expect(verifyCertificate(cert, 'inst-abc')).toBeNull() + }) + + it('returns null when instance_id does not match', () => { + const cert = signCert({ instance_id: 'inst-abc' }) + expect(verifyCertificate(cert, 'inst-DIFFERENT')).toBeNull() + }) + + it('verifies a cert signed by a second key when two keys are in PUBLIC_KEYS', async () => { + const { secretKey: altSecret, publicKey: altPublic } = generateKeys('public') + + // Temporarily inject the alt key into PUBLIC_KEYS for this test + const { PUBLIC_KEYS } = await import('./public-keys') + const original = [...PUBLIC_KEYS] + PUBLIC_KEYS.push(altPublic) + + try { + const cert = signCert({ instance_id: 'inst-xyz' }, altSecret) + const result = verifyCertificate(cert, 'inst-xyz') + expect(result).not.toBeNull() + expect(result?.plan).toBe('pro') + } finally { + PUBLIC_KEYS.length = 0 + for (const k of original) PUBLIC_KEYS.push(k) + } + }) + + it('returns null when the cert is not a valid PASETO token at all', () => { + expect(verifyCertificate('not-a-token', 'inst-abc')).toBeNull() + }) +}) diff --git a/server/licensing/verify.ts b/server/licensing/verify.ts new file mode 100644 index 00000000..68dfde84 --- /dev/null +++ b/server/licensing/verify.ts @@ -0,0 +1,44 @@ +import type { LicenseEntitlement } from '@shared/types' +import { verify } from 'paseto-ts/v4' +import { PUBLIC_KEYS } from './public-keys' + +// Attempt to verify a PASETO v4.public cert against each known public key. +// Returns the parsed entitlement only when ALL of the following hold: +// 1. Signature is valid for one of the PUBLIC_KEYS +// 2. expires_at has not passed +// 3. instance_id matches the provided instanceId +// Returns null (never throws) for any invalid cert so feature gates silently lock. +export function verifyCertificate(cert: string, instanceId: string): LicenseEntitlement | null { + for (const key of PUBLIC_KEYS) { + const entitlement = tryVerify(cert, key, instanceId) + if (entitlement !== null) { + return entitlement + } + } + return null +} + +function tryVerify(cert: string, publicKey: string, instanceId: string): LicenseEntitlement | null { + try { + const { payload } = verify(publicKey, cert, { validatePayload: false }) + + if (new Date(payload.expires_at) <= new Date()) { + return null + } + + if (payload.instance_id !== instanceId) { + return null + } + + return { + account_id: payload.account_id, + instance_id: payload.instance_id, + plan: payload.plan, + features: payload.features, + issued_at: payload.issued_at, + expires_at: payload.expires_at, + } + } catch { + return null + } +} diff --git a/shared/types/licensing.ts b/shared/types/licensing.ts index e6ab2642..762dff0f 100644 --- a/shared/types/licensing.ts +++ b/shared/types/licensing.ts @@ -5,8 +5,8 @@ export interface LicenseEntitlement { instance_id: string plan: 'community' | 'pro' features: ProFeature[] - issued_at: number - expires_at: number + issued_at: string + expires_at: string } export interface BindingState {