mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-30 18:31:36 +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:
Generated
+103
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user