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
+103
View File
@@ -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",
+1
View File
@@ -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",
+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
}
}
+2 -2
View File
@@ -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 {