mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-29 18:05:37 +08:00
feat(licensing): harden cloud pairing — env keys, clear errors, confirm handshake (#424)
Three related robustness fixes for the cloud pairing flow: 1. Trusted license public keys are env-configurable (ZPAN_LICENSE_PUBLIC_KEYS) instead of hardcoding dev keys in source — a leaked dev key is rotated via config and never baked into production builds. Registered in all platform factories. 2. Certificate verification surfaces a specific rejection reason (signature/issuer/instance/expired/host), and the pairing modal distinguishes a cert-verification failure from a genuine timeout instead of showing both as "expired". On failure the poll handler rolls back the orphaned cloud binding. 3. After verifying + storing the certificate, the instance confirms the binding to the cloud (zpan-cloud-sdk 2.1.0's POST /licenses/:id/confirm) so the cloud pairing page resolves to success only once the instance actually accepted it. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e56d3dc61a
commit
e6e67c5fe4
@@ -10,3 +10,9 @@ TRUSTED_ORIGINS=http://localhost:5185
|
||||
|
||||
# Local ZPan Cloud URL.
|
||||
ZPAN_CLOUD_URL=http://localhost:5186
|
||||
|
||||
# Trusted license signing public keys (PASERK k4.public.*, comma-separated).
|
||||
# Dev keys are configured here — NOT hardcoded in source — so a leaked dev key is
|
||||
# rotated via config and never trusted by production builds. Pair this with the
|
||||
# matching LICENSE_SIGNING_KEY in zpan-cloud/.dev.vars (run `npm run gen-keypair`).
|
||||
ZPAN_LICENSE_PUBLIC_KEYS=
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"yet-another-react-lightbox": "^3.30.1",
|
||||
"zod": "^4.4.3",
|
||||
"zpan-cloud-sdk": "^2.0.1"
|
||||
"zpan-cloud-sdk": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
|
||||
Generated
+5
-5
@@ -200,8 +200,8 @@ importers:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
zpan-cloud-sdk:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(hono@4.12.21)(zod@4.4.3)
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(hono@4.12.21)(zod@4.4.3)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.4.10
|
||||
@@ -5623,8 +5623,8 @@ packages:
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zpan-cloud-sdk@2.0.1:
|
||||
resolution: {integrity: sha512-o2VqkNhD4NRM8RE5HIJmNAInFO6Mqtm7zea5ys4lAK5O0bs2QYXuEKrOYxGth4MES7VQ9XpoT9Gs585/c7m4DQ==}
|
||||
zpan-cloud-sdk@2.1.0:
|
||||
resolution: {integrity: sha512-3XyIKfjYDUQDaHHzA9gAc4GCqcXkfY81wwm+eXxprNu+2SEjtMdjKZ9805Kn/ibUv+sIIeLv/3xeXE0FLe0uGw==}
|
||||
peerDependencies:
|
||||
hono: ^4.7.11
|
||||
zod: ^4.3.6
|
||||
@@ -11051,7 +11051,7 @@ snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zpan-cloud-sdk@2.0.1(hono@4.12.21)(zod@4.4.3):
|
||||
zpan-cloud-sdk@2.1.0(hono@4.12.21)(zod@4.4.3):
|
||||
dependencies:
|
||||
hono: 4.12.21
|
||||
zod: 4.4.3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { verify } from 'paseto-ts/v4'
|
||||
import { z } from 'zod'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
import { getTrustedPublicKeys } from './public-keys'
|
||||
import { trustedIssuerFromCloudUrl } from './verify'
|
||||
|
||||
const CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS = 5 * 60
|
||||
@@ -31,7 +31,7 @@ export interface VerifyCloudEventTokenOptions {
|
||||
}
|
||||
|
||||
export function verifyCloudEventToken(token: string, options: VerifyCloudEventTokenOptions): CloudEventToken | null {
|
||||
for (const key of PUBLIC_KEYS) {
|
||||
for (const key of getTrustedPublicKeys()) {
|
||||
const event = tryVerifyCloudEventToken(token, key, options)
|
||||
if (event) return event
|
||||
}
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getTrustedPublicKeys, PUBLIC_KEYS, registerEnvPublicKeys } from './public-keys'
|
||||
|
||||
describe('PUBLIC_KEYS', () => {
|
||||
it('exports a non-empty array', () => {
|
||||
expect(PUBLIC_KEYS).toBeInstanceOf(Array)
|
||||
expect(PUBLIC_KEYS.length).toBeGreaterThan(0)
|
||||
describe('public keys', () => {
|
||||
afterEach(() => {
|
||||
registerEnvPublicKeys(undefined)
|
||||
})
|
||||
|
||||
it('each entry is a PASERK v4 public key', () => {
|
||||
for (const key of PUBLIC_KEYS) {
|
||||
it('trusts the builtin staging + production keys by default', () => {
|
||||
const trusted = getTrustedPublicKeys()
|
||||
expect(trusted.length).toBeGreaterThan(0)
|
||||
for (const key of trusted) {
|
||||
expect(key).toMatch(/^k4\.public\./)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not bake any dev key into the build (runtime layer starts empty)', () => {
|
||||
registerEnvPublicKeys(undefined)
|
||||
expect(PUBLIC_KEYS).toEqual([])
|
||||
})
|
||||
|
||||
it('registers env-configured keys and merges them with the builtins', () => {
|
||||
const builtinCount = getTrustedPublicKeys().length
|
||||
registerEnvPublicKeys('k4.public.aaa, k4.public.bbb')
|
||||
expect(PUBLIC_KEYS).toEqual(['k4.public.aaa', 'k4.public.bbb'])
|
||||
expect(getTrustedPublicKeys()).toHaveLength(builtinCount + 2)
|
||||
})
|
||||
|
||||
it('parses whitespace- or comma-separated keys and ignores malformed entries', () => {
|
||||
registerEnvPublicKeys('k4.public.one\n k4.public.two , not-a-key')
|
||||
expect(PUBLIC_KEYS).toEqual(['k4.public.one', 'k4.public.two'])
|
||||
})
|
||||
|
||||
it('clears the runtime layer when re-registered with no value', () => {
|
||||
registerEnvPublicKeys('k4.public.one')
|
||||
expect(PUBLIC_KEYS).toHaveLength(1)
|
||||
registerEnvPublicKeys(undefined)
|
||||
expect(PUBLIC_KEYS).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,54 @@
|
||||
// Cloud public keys for verifying PASETO v4 entitlement certificates.
|
||||
//
|
||||
// ZPan verifies every entitlement certificate against this list.
|
||||
// Multiple keys are supported to allow zero-downtime key rotation:
|
||||
// 1. Add the new key here and deploy ZPan.
|
||||
// 2. Update the cloud Worker secret (LICENSE_SIGNING_KEY) to the new key and deploy.
|
||||
// 3. After 24 h (old certs expired), remove the old key here and deploy ZPan again.
|
||||
// ZPan verifies every entitlement certificate against the trusted set, which is
|
||||
// merged from two sources at runtime (see getTrustedPublicKeys):
|
||||
//
|
||||
// 1. BUILTIN_PUBLIC_KEYS — staging + production. Their secret halves live only
|
||||
// in CF Worker secrets, so baking the public halves into the build is safe.
|
||||
//
|
||||
// 2. PUBLIC_KEYS — the runtime layer, populated from the ZPAN_LICENSE_PUBLIC_KEYS
|
||||
// env var (see registerEnvPublicKeys). Dev keys belong HERE, never in source:
|
||||
// a dev secret in zpan-cloud/.dev.vars leaks easily, and a hardcoded dev
|
||||
// public key would make every production build trust it — a leaked dev secret
|
||||
// could then mint certs accepted in production. Keeping dev keys in env means a
|
||||
// leak is rotated via config (no code change), and production never trusts a
|
||||
// dev key unless its operator explicitly sets the var.
|
||||
//
|
||||
// Rotation (staging/prod): add the new key to BUILTIN_PUBLIC_KEYS and deploy ZPan;
|
||||
// update the cloud LICENSE_SIGNING_KEY and deploy; after 24 h (old certs expired)
|
||||
// drop the old key and deploy again.
|
||||
//
|
||||
// Keys are PASERK k4.public.* strings (Ed25519, 32 raw bytes, base64url-encoded).
|
||||
export const PUBLIC_KEYS: string[] = [
|
||||
// local zpan-cloud dev key — matches zpan-cloud/.dev.vars on this machine
|
||||
'k4.public.uU2s4lgXfltPCUnbXcb5bOm3hi2AvGfM0k1ufQ3R0qs',
|
||||
|
||||
const BUILTIN_PUBLIC_KEYS: readonly string[] = [
|
||||
// zpan-cloud staging key — provisioned 2026-05-09
|
||||
'k4.public.CCpUZ1yRWkFQy4fPZAblCYfzeJn4vDwPQrjtfiySwFc',
|
||||
// cloud.zpan.space production key — provisioned 2026-04-24
|
||||
'k4.public.N_r-lhhAfhR8sOk0pl8zlzU5dNRl2tvpUT94uODPZ1w',
|
||||
]
|
||||
|
||||
// Runtime-registered keys: env-configured dev/rotation keys, plus test-injected
|
||||
// keys. Mutated in place so tests can swap the trusted set without re-wiring imports.
|
||||
export const PUBLIC_KEYS: string[] = []
|
||||
|
||||
const PASERK_PUBLIC_PREFIX = 'k4.public.'
|
||||
|
||||
function parseEnvPublicKeys(raw: string | undefined | null): string[] {
|
||||
if (!raw) return []
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key.startsWith(PASERK_PUBLIC_PREFIX))
|
||||
}
|
||||
|
||||
// Called once per platform creation with ZPAN_LICENSE_PUBLIC_KEYS. Idempotent:
|
||||
// re-registering the same env value (e.g. per CF request) just re-sets the layer.
|
||||
export function registerEnvPublicKeys(raw: string | undefined | null): void {
|
||||
const parsed = parseEnvPublicKeys(raw)
|
||||
PUBLIC_KEYS.length = 0
|
||||
PUBLIC_KEYS.push(...parsed)
|
||||
}
|
||||
|
||||
export function getTrustedPublicKeys(): string[] {
|
||||
return [...BUILTIN_PUBLIC_KEYS, ...PUBLIC_KEYS]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { generateKeys, sign } from 'paseto-ts/v4'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
import { verifyCertificate } from './verify'
|
||||
import { verifyCertificate, verifyCertificateResult } from './verify'
|
||||
|
||||
const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public')
|
||||
const originalKeys: string[] = []
|
||||
@@ -105,3 +105,40 @@ describe('verifyCertificate', () => {
|
||||
expect(verifyCertificate('not-a-token', { instanceId: 'inst-abc' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCertificateResult', () => {
|
||||
it('returns ok with the assertion for a valid cert', () => {
|
||||
const result = verifyCertificateResult(signCert(), { instanceId: 'inst-abc', currentHost: 'zpan.example.com' })
|
||||
expect(result).toEqual({ ok: true, assertion: expect.objectContaining({ edition: 'pro' }) })
|
||||
})
|
||||
|
||||
it('reports "signature" when no trusted key can verify the token (mismatched signing key)', () => {
|
||||
const { secretKey: untrusted } = generateKeys('public')
|
||||
const cert = signCert({}, untrusted)
|
||||
expect(verifyCertificateResult(cert, { instanceId: 'inst-abc' })).toEqual({ ok: false, reason: 'signature' })
|
||||
})
|
||||
|
||||
it('reports "expired" when the signature is valid but the cert is past expiry', () => {
|
||||
const cert = signCert({ expiresAt: nowSec() - 1 })
|
||||
expect(verifyCertificateResult(cert, { instanceId: 'inst-abc' })).toEqual({ ok: false, reason: 'expired' })
|
||||
})
|
||||
|
||||
it('reports "instance" when the instanceId does not match', () => {
|
||||
expect(verifyCertificateResult(signCert(), { instanceId: 'inst-OTHER' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'instance',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports "issuer" when the issuer is untrusted', () => {
|
||||
const cert = signCert({ issuer: 'https://evil.example.com' })
|
||||
expect(verifyCertificateResult(cert, { instanceId: 'inst-abc' })).toEqual({ ok: false, reason: 'issuer' })
|
||||
})
|
||||
|
||||
it('reports "host" when the current host is not authorized', () => {
|
||||
expect(verifyCertificateResult(signCert(), { instanceId: 'inst-abc', currentHost: 'other.example.com' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'host',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+75
-38
@@ -1,7 +1,7 @@
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '@shared/constants'
|
||||
import type { LicenseAssertion } from '@shared/types'
|
||||
import { verify } from 'paseto-ts/v4'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
import { getTrustedPublicKeys } from './public-keys'
|
||||
|
||||
export interface VerifyCertificateOptions {
|
||||
instanceId: string
|
||||
@@ -9,6 +9,24 @@ export interface VerifyCertificateOptions {
|
||||
cloudBaseUrl?: string | null
|
||||
}
|
||||
|
||||
// Why a certificate was rejected. 'signature' means no trusted public key could
|
||||
// verify the token — i.e. the cloud signed with a key ZPan doesn't trust (the most
|
||||
// common cause: a rotated/mismatched signing key). The rest mean the signature was
|
||||
// valid but a claim did not match.
|
||||
export type CertificateRejectionReason =
|
||||
| 'signature'
|
||||
| 'type'
|
||||
| 'issuer'
|
||||
| 'instance'
|
||||
| 'edition'
|
||||
| 'not_yet_valid'
|
||||
| 'expired'
|
||||
| 'host'
|
||||
|
||||
export type VerifyCertificateResult =
|
||||
| { ok: true; assertion: LicenseAssertion }
|
||||
| { ok: false; reason: CertificateRejectionReason }
|
||||
|
||||
export function trustedIssuerFromCloudUrl(baseUrl: string | null | undefined): string {
|
||||
const raw = baseUrl || ZPAN_CLOUD_URL_DEFAULT
|
||||
try {
|
||||
@@ -28,46 +46,65 @@ export function normalizeHost(host: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
export function verifyCertificate(cert: string, options: VerifyCertificateOptions): LicenseAssertion | null {
|
||||
for (const key of PUBLIC_KEYS) {
|
||||
const assertion = tryVerify(cert, key, options)
|
||||
if (assertion !== null) {
|
||||
return assertion
|
||||
}
|
||||
}
|
||||
return null
|
||||
const result = verifyCertificateResult(cert, options)
|
||||
return result.ok ? result.assertion : null
|
||||
}
|
||||
|
||||
function tryVerify(cert: string, publicKey: string, options: VerifyCertificateOptions): LicenseAssertion | null {
|
||||
// Detailed variant: returns the specific rejection reason so callers (e.g. the
|
||||
// pairing poll handler) can surface why a certificate failed instead of a bare null.
|
||||
export function verifyCertificateResult(cert: string, options: VerifyCertificateOptions): VerifyCertificateResult {
|
||||
let claimReason: CertificateRejectionReason | null = null
|
||||
|
||||
for (const key of getTrustedPublicKeys()) {
|
||||
const outcome = tryVerify(cert, key, options)
|
||||
if (outcome.ok) return outcome
|
||||
// Signature passed for this key but a claim failed — remember the first such
|
||||
// reason. We keep looping in case another key yields a fully valid assertion.
|
||||
if (outcome.reason !== 'signature' && claimReason === null) {
|
||||
claimReason = outcome.reason
|
||||
}
|
||||
}
|
||||
|
||||
// A claim reason outranks 'signature': if any key validated the signature, the
|
||||
// real problem is the claim, not a key mismatch.
|
||||
return { ok: false, reason: claimReason ?? 'signature' }
|
||||
}
|
||||
|
||||
function tryVerify(cert: string, publicKey: string, options: VerifyCertificateOptions): VerifyCertificateResult {
|
||||
let payload: LicenseAssertion
|
||||
try {
|
||||
const { payload } = verify<LicenseAssertion>(publicKey, cert, { validatePayload: false })
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const currentHost = normalizeHost(options.currentHost)
|
||||
const authorizedHosts = Array.isArray(payload.authorizedHosts)
|
||||
? payload.authorizedHosts.map((host) => normalizeHost(host)).filter((host): host is string => Boolean(host))
|
||||
: []
|
||||
|
||||
if (payload.type !== 'zpan.license') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (payload.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (payload.instanceId !== options.instanceId || (payload.edition !== 'pro' && payload.edition !== 'business')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (payload.notBefore > now || payload.expiresAt <= now) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentHost && !authorizedHosts.includes(currentHost)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...payload, authorizedHosts }
|
||||
;({ payload } = verify<LicenseAssertion>(publicKey, cert, { validatePayload: false }))
|
||||
} catch {
|
||||
return null
|
||||
return { ok: false, reason: 'signature' }
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const currentHost = normalizeHost(options.currentHost)
|
||||
const authorizedHosts = Array.isArray(payload.authorizedHosts)
|
||||
? payload.authorizedHosts.map((host) => normalizeHost(host)).filter((host): host is string => Boolean(host))
|
||||
: []
|
||||
|
||||
if (payload.type !== 'zpan.license') {
|
||||
return { ok: false, reason: 'type' }
|
||||
}
|
||||
if (payload.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) {
|
||||
return { ok: false, reason: 'issuer' }
|
||||
}
|
||||
if (payload.instanceId !== options.instanceId) {
|
||||
return { ok: false, reason: 'instance' }
|
||||
}
|
||||
if (payload.edition !== 'pro' && payload.edition !== 'business') {
|
||||
return { ok: false, reason: 'edition' }
|
||||
}
|
||||
if (payload.notBefore > now) {
|
||||
return { ok: false, reason: 'not_yet_valid' }
|
||||
}
|
||||
if (payload.expiresAt <= now) {
|
||||
return { ok: false, reason: 'expired' }
|
||||
}
|
||||
if (currentHost && !authorizedHosts.includes(currentHost)) {
|
||||
return { ok: false, reason: 'host' }
|
||||
}
|
||||
|
||||
return { ok: true, assertion: { ...payload, authorizedHosts } }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { drizzle } from 'drizzle-orm/d1'
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import * as schema from '../db/schema'
|
||||
import { registerEnvPublicKeys } from '../licensing/public-keys'
|
||||
import type { Platform } from './interface'
|
||||
|
||||
interface CloudflareEnv {
|
||||
@@ -11,6 +12,8 @@ interface CloudflareEnv {
|
||||
export function createCloudflarePlatform(env: CloudflareEnv): Platform {
|
||||
const db = drizzle(env.DB, { schema: { ...schema, ...authSchema } })
|
||||
|
||||
registerEnvPublicKeys(typeof env.ZPAN_LICENSE_PUBLIC_KEYS === 'string' ? env.ZPAN_LICENSE_PUBLIC_KEYS : undefined)
|
||||
|
||||
return {
|
||||
db,
|
||||
getEnv(key: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/libsql'
|
||||
import { migrate } from 'drizzle-orm/libsql/migrator'
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import * as schema from '../db/schema'
|
||||
import { registerEnvPublicKeys } from '../licensing/public-keys'
|
||||
import type { Platform } from './interface'
|
||||
|
||||
interface LibsqlEnv {
|
||||
@@ -23,6 +24,8 @@ export async function createLibsqlPlatform(env: LibsqlEnv): Promise<Platform> {
|
||||
|
||||
await migrate(db, { migrationsFolder })
|
||||
|
||||
registerEnvPublicKeys(envRecord.ZPAN_LICENSE_PUBLIC_KEYS ?? process.env.ZPAN_LICENSE_PUBLIC_KEYS)
|
||||
|
||||
return {
|
||||
db,
|
||||
getEnv(key: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import * as schema from '../db/schema'
|
||||
import { registerEnvPublicKeys } from '../licensing/public-keys'
|
||||
import type { Platform } from './interface'
|
||||
|
||||
export function createNodePlatform(): Platform {
|
||||
@@ -15,6 +16,8 @@ export function createNodePlatform(): Platform {
|
||||
|
||||
migrate(db, { migrationsFolder })
|
||||
|
||||
registerEnvPublicKeys(process.env.ZPAN_LICENSE_PUBLIC_KEYS)
|
||||
|
||||
return {
|
||||
db,
|
||||
getEnv(key: string) {
|
||||
|
||||
@@ -22,9 +22,9 @@ function nowSec(): number {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
function signCert(instanceId: string): string {
|
||||
function signCert(instanceId: string, secret: string = TEST_SECRET): string {
|
||||
const now = nowSec()
|
||||
return sign(TEST_SECRET, {
|
||||
return sign(secret, {
|
||||
type: 'zpan.license',
|
||||
issuer: 'https://cloud.zpan.space',
|
||||
subject: 'bind-1',
|
||||
@@ -195,15 +195,18 @@ describe('GET /api/licensing/pair/:code/poll', () => {
|
||||
const instanceId = await getOrCreateInstanceId(db)
|
||||
const certificate = signCert(instanceId)
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeCloudResponse({
|
||||
status: 'approved',
|
||||
refreshToken: 'pair-rt',
|
||||
certificate,
|
||||
binding: { id: 'bind-1', storeId: 'store-1', instanceId, authorizedHosts: ['localhost'] },
|
||||
account: { id: 'acct-1', email: 'acct@example.com' },
|
||||
}),
|
||||
)
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
makeCloudResponse({
|
||||
status: 'approved',
|
||||
refreshToken: 'pair-rt',
|
||||
certificate,
|
||||
binding: { id: 'bind-1', storeId: 'store-1', instanceId, authorizedHosts: ['localhost'] },
|
||||
account: { id: 'acct-1', email: 'acct@example.com' },
|
||||
}),
|
||||
)
|
||||
// The confirm callback to the cloud after the cert is verified + stored.
|
||||
.mockResolvedValueOnce(makeCloudResponse(null, 204))
|
||||
|
||||
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
||||
|
||||
@@ -211,7 +214,9 @@ describe('GET /api/licensing/pair/:code/poll', () => {
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBe('pair-rt')
|
||||
expect(state.cachedCert).toBe(certificate)
|
||||
expect(vi.mocked(fetch).mock.calls).toHaveLength(1)
|
||||
// Poll + confirm.
|
||||
expect(vi.mocked(fetch).mock.calls).toHaveLength(2)
|
||||
expect(String(vi.mocked(fetch).mock.calls.at(-1)?.[0])).toContain('bind-1/confirm')
|
||||
})
|
||||
|
||||
it('rejects approved responses with an invalid certificate', async () => {
|
||||
@@ -267,12 +272,51 @@ describe('GET /api/licensing/pair/:code/poll', () => {
|
||||
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'invalid_certificate' })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: 'invalid_certificate',
|
||||
reason: 'incomplete_response',
|
||||
})
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.status).toBe('disconnected')
|
||||
expect(state.refreshToken).toBeNull()
|
||||
expect(state.cachedCert).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an untrusted signing key and rolls back the orphaned cloud binding', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const instanceId = await getOrCreateInstanceId(db)
|
||||
|
||||
// Sign with a key ZPan does not trust — simulates a rotated/mismatched cloud
|
||||
// signing key (the real-world "lost private key" scenario).
|
||||
const { secretKey: untrustedSecret } = generateKeys('public')
|
||||
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
makeCloudResponse({
|
||||
status: 'approved',
|
||||
refreshToken: 'rt-secret',
|
||||
certificate: signCert(instanceId, untrustedSecret),
|
||||
binding: { id: 'cb-1', instanceId, storeId: 'store-1', authorizedHosts: [] },
|
||||
account: { id: 'acct-1', email: 'owner@example.com' },
|
||||
}),
|
||||
)
|
||||
// The rollback unbind call to the cloud.
|
||||
.mockResolvedValueOnce(makeCloudResponse({ ok: true }))
|
||||
|
||||
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: 'invalid_certificate',
|
||||
reason: 'signature',
|
||||
})
|
||||
// ZPan stored nothing; the cloud binding was released.
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBeNull()
|
||||
const unbindCall = vi.mocked(fetch).mock.calls.at(-1)
|
||||
expect(String(unbindCall?.[0])).toContain('cb-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/licensing/refresh', () => {
|
||||
|
||||
@@ -5,11 +5,17 @@ import { getOrCreateInstanceId } from '../licensing/instance-id'
|
||||
import { buildCloudInstanceInfo, runtimeInfo } from '../licensing/instance-info'
|
||||
import { clearLicenseBinding, createLicenseBinding, loadLicenseState } from '../licensing/license-state'
|
||||
import { performRefresh } from '../licensing/refresh'
|
||||
import { normalizeHost, verifyCertificate } from '../licensing/verify'
|
||||
import { normalizeHost, verifyCertificateResult } from '../licensing/verify'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { recordActivity } from '../services/activity'
|
||||
import { createPairing, pollPairing, unbindCloudLicense } from '../services/licensing-cloud'
|
||||
import {
|
||||
confirmCloudLicense,
|
||||
createPairing,
|
||||
type PairingPollResponse,
|
||||
pollPairing,
|
||||
unbindCloudLicense,
|
||||
} from '../services/licensing-cloud'
|
||||
import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin'
|
||||
|
||||
function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string {
|
||||
@@ -35,6 +41,20 @@ async function getRequestHost(c: {
|
||||
return normalizeHost(forwardedHost) ?? new URL(c.req.url).host
|
||||
}
|
||||
|
||||
// Best-effort release of a cloud binding ZPan couldn't accept. Returns the failure
|
||||
// message (surfaced for diagnostics) or null. Leaving the cloud binding orphaned is
|
||||
// the safe direction — ZPan stays unbound either way — so a failure here does not
|
||||
// change the user-facing outcome.
|
||||
async function rollbackCloudBinding(baseUrl: string, result: PairingPollResponse): Promise<string | null> {
|
||||
if (!result.refreshToken || !result.binding?.id) return null
|
||||
try {
|
||||
await unbindCloudLicense(baseUrl, result.binding.id, result.refreshToken)
|
||||
return null
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Cloud unbind failed'
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
|
||||
@@ -58,38 +78,51 @@ const app = new Hono<Env>()
|
||||
|
||||
const result = await pollPairing(baseUrl, code)
|
||||
|
||||
if (result.status === 'approved' && result.refreshToken && result.certificate) {
|
||||
const entitlement = {
|
||||
refreshToken: result.refreshToken,
|
||||
certificate: result.certificate,
|
||||
binding: result.binding,
|
||||
account: result.account,
|
||||
}
|
||||
if (result.status === 'approved') {
|
||||
const instanceId = await getOrCreateInstanceId(db)
|
||||
const cert = entitlement.certificate
|
||||
const assertion = verifyCertificate(cert, {
|
||||
instanceId,
|
||||
currentHost: await getRequestHost(c),
|
||||
cloudBaseUrl: baseUrl,
|
||||
})
|
||||
if (!assertion || !entitlement.binding?.storeId || !entitlement.account) {
|
||||
return c.json({ error: 'invalid_certificate' }, 502)
|
||||
const verification = result.certificate
|
||||
? verifyCertificateResult(result.certificate, {
|
||||
instanceId,
|
||||
currentHost: await getRequestHost(c),
|
||||
cloudBaseUrl: baseUrl,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!verification?.ok || !result.refreshToken || !result.binding?.storeId || !result.account) {
|
||||
// The cloud approved and created a binding, but ZPan can't accept this
|
||||
// certificate (most often: signed by a key ZPan doesn't trust). Roll back
|
||||
// the orphaned cloud binding so the two sides don't drift and retries don't
|
||||
// pile up dangling bindings.
|
||||
const cloudUnbindError = await rollbackCloudBinding(baseUrl, result)
|
||||
const reason = verification ? (verification.ok ? 'incomplete_response' : verification.reason) : 'no_certificate'
|
||||
return c.json({ error: 'invalid_certificate', reason, cloud_unbind_error: cloudUnbindError }, 502)
|
||||
}
|
||||
|
||||
const assertion = verification.assertion
|
||||
await createLicenseBinding(db, {
|
||||
cloudBindingId: entitlement.binding.id,
|
||||
cloudStoreId: entitlement.binding.storeId,
|
||||
cloudBindingId: result.binding.id,
|
||||
cloudStoreId: result.binding.storeId,
|
||||
instanceId,
|
||||
cloudAccountId: entitlement.account.id,
|
||||
cloudAccountEmail: entitlement.account.email,
|
||||
refreshToken: entitlement.refreshToken,
|
||||
cachedCert: cert,
|
||||
cloudAccountId: result.account.id,
|
||||
cloudAccountEmail: result.account.email,
|
||||
refreshToken: result.refreshToken,
|
||||
cachedCert: result.certificate!,
|
||||
cachedExpiresAt: assertion.expiresAt,
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
invalidateEntitlementCache()
|
||||
|
||||
// Report back that we verified + stored the certificate, so the cloud pairing
|
||||
// page resolves to success instead of claiming it at approval time. Best-effort:
|
||||
// the binding is already active locally, so a failed confirm only leaves the
|
||||
// cloud page waiting — it does not break licensing here.
|
||||
try {
|
||||
await confirmCloudLicense(baseUrl, result.binding.id, result.refreshToken)
|
||||
} catch {
|
||||
// ignore — binding works regardless; cloud page falls back to its timeout state
|
||||
}
|
||||
|
||||
const userId = c.get('userId')!
|
||||
const orgId = c.get('orgId')!
|
||||
await recordActivity(db, {
|
||||
@@ -97,21 +130,17 @@ const app = new Hono<Env>()
|
||||
userId,
|
||||
action: 'license_pair',
|
||||
targetType: 'license',
|
||||
targetName: entitlement.account.email ?? entitlement.account.id,
|
||||
metadata: { edition: assertion.edition, cloudAccountId: entitlement.account.id },
|
||||
targetName: result.account.email ?? result.account.id,
|
||||
metadata: { edition: assertion.edition, cloudAccountId: result.account.id },
|
||||
})
|
||||
|
||||
return c.json({
|
||||
status: 'approved' as const,
|
||||
edition: assertion.edition,
|
||||
cloud_store_id: entitlement.binding.storeId,
|
||||
cloud_store_id: result.binding.storeId,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.status === 'approved') {
|
||||
return c.json({ error: 'invalid_pairing_response' }, 502)
|
||||
}
|
||||
|
||||
return c.json({ status: result.status })
|
||||
})
|
||||
|
||||
|
||||
@@ -199,6 +199,19 @@ export async function unbindCloudLicense(baseUrl: string, licenseId: string, ref
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the cloud this instance verified + stored the certificate, so the pairing
|
||||
// page can resolve to success instead of claiming success the moment it approves.
|
||||
export async function confirmCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void> {
|
||||
const res = await cloudResponse(
|
||||
createBoundCloudClient(baseUrl, refreshToken).licenses[':id'].confirm.$post({ param: { id: licenseId } }),
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Cloud confirm failed: ${res.status} ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapCloudData<T>(data: unknown): T {
|
||||
if (data && typeof data === 'object' && 'data' in data) return data.data as T
|
||||
return data as T
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { entitlementQueryKey } from '@/hooks/useEntitlement'
|
||||
import type { PairingInfo } from '@/lib/api'
|
||||
import { connectCloud, pollPairing } from '@/lib/api'
|
||||
import { ApiError, connectCloud, type PairingInfo, pollPairing } from '@/lib/api'
|
||||
|
||||
type PairingState = 'loading' | 'waiting' | 'denied' | 'expired' | 'error'
|
||||
type PairingState = 'loading' | 'waiting' | 'denied' | 'expired' | 'certError' | 'error'
|
||||
|
||||
interface PairingModalProps {
|
||||
open: boolean
|
||||
@@ -89,9 +88,11 @@ export function PairingModal({ open, onOpenChange }: PairingModalProps) {
|
||||
} else {
|
||||
setState('expired')
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
stopPolling()
|
||||
setState('error')
|
||||
// 502 = cloud approved the pairing but ZPan rejected the certificate
|
||||
// (e.g. signed by an untrusted key). Distinct from a genuine timeout.
|
||||
setState(err instanceof ApiError && err.status === 502 ? 'certError' : 'error')
|
||||
}
|
||||
}, 5000)
|
||||
},
|
||||
@@ -127,7 +128,7 @@ export function PairingModal({ open, onOpenChange }: PairingModalProps) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const isTerminal = state === 'denied' || state === 'expired' || state === 'error'
|
||||
const isTerminal = state === 'denied' || state === 'expired' || state === 'certError' || state === 'error'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -179,9 +180,13 @@ export function PairingModal({ open, onOpenChange }: PairingModalProps) {
|
||||
|
||||
{state === 'denied' && <p className="text-sm text-destructive">{t('settings.billing.pairing.denied')}</p>}
|
||||
|
||||
{(state === 'expired' || state === 'error') && (
|
||||
<p className="text-sm text-destructive">{t('settings.billing.pairing.expired')}</p>
|
||||
{state === 'expired' && <p className="text-sm text-destructive">{t('settings.billing.pairing.expired')}</p>}
|
||||
|
||||
{state === 'certError' && (
|
||||
<p className="text-sm text-destructive">{t('settings.billing.pairing.certError')}</p>
|
||||
)}
|
||||
|
||||
{state === 'error' && <p className="text-sm text-destructive">{t('settings.billing.pairing.error')}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
|
||||
@@ -1283,6 +1283,8 @@
|
||||
"settings.billing.pairing.retry": "Retry",
|
||||
"settings.billing.pairing.denied": "Pairing was denied. Please try again.",
|
||||
"settings.billing.pairing.expired": "Pairing code has expired. Please try again.",
|
||||
"settings.billing.pairing.error": "Pairing failed. Please try again.",
|
||||
"settings.billing.pairing.certError": "Paired, but the license certificate could not be verified — the cloud signing key isn't trusted by this instance. Check your key configuration (ZPAN_LICENSE_PUBLIC_KEYS) and try again.",
|
||||
"settings.billing.pairing.connected": "Connected to ZPan Cloud ({{email}})",
|
||||
"settings.billing.bound.pro.title": "Pro License",
|
||||
"settings.billing.bound.business.title": "Business License",
|
||||
|
||||
@@ -1283,6 +1283,8 @@
|
||||
"settings.billing.pairing.retry": "重试",
|
||||
"settings.billing.pairing.denied": "配对已被拒绝,请重试。",
|
||||
"settings.billing.pairing.expired": "配对码已过期,请重试。",
|
||||
"settings.billing.pairing.error": "配对失败,请重试。",
|
||||
"settings.billing.pairing.certError": "配对成功,但许可证书无法验证——云端的签名密钥未被本实例信任。请检查密钥配置(ZPAN_LICENSE_PUBLIC_KEYS)后重试。",
|
||||
"settings.billing.pairing.connected": "已连接到 ZPan Cloud({{email}})",
|
||||
"settings.billing.bound.pro.title": "Pro License",
|
||||
"settings.billing.bound.business.title": "Business License",
|
||||
|
||||
Reference in New Issue
Block a user