feat(licensing): v2.6 Z2 — Ed25519 verify + PUBLIC_KEYS + entitlement cache (#341)

* 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 <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-24 07:34:32 -04:00
committed by GitHub
co-authored by Bob
parent 85554512c4
commit b9cea3e912
8 changed files with 306 additions and 2 deletions
+50
View File
@@ -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<EntitlementSummary | null> {
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
}
+16
View File
@@ -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\./)
}
})
})
+10
View File
@@ -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']
+80
View File
@@ -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<string, unknown> = {}, 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()
})
})
+44
View File
@@ -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<LicenseEntitlement>(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
}
}