mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
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:
@@ -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
|
||||
}
|
||||
@@ -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\./)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user