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:
Jasper Van
2026-06-10 14:43:46 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e56d3dc61a
commit e6e67c5fe4
17 changed files with 360 additions and 114 deletions
+6
View File
@@ -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
View File
@@ -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",
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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
}
+33 -8
View File
@@ -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([])
})
})
+45 -8
View File
@@ -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]
}
+38 -1
View File
@@ -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
View File
@@ -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 } }
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {
+59 -30
View File
@@ -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 })
})
+13
View File
@@ -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 -8
View File
@@ -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">
+2
View File
@@ -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",
+2
View File
@@ -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",