From e6e67c5fe4afdc30e2002b44d3449f762c2329e7 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Wed, 10 Jun 2026 14:43:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(licensing):=20harden=20cloud=20pairing=20?= =?UTF-8?q?=E2=80=94=20env=20keys,=20clear=20errors,=20confirm=20handshake?= =?UTF-8?q?=20(#424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dev.vars.example | 6 + package.json | 2 +- pnpm-lock.yaml | 10 +- server/licensing/cloud-event-token.ts | 4 +- server/licensing/public-keys.test.ts | 41 +++++-- server/licensing/public-keys.ts | 53 ++++++-- server/licensing/verify.test.ts | 39 +++++- server/licensing/verify.ts | 113 ++++++++++++------ server/platform/cloudflare.ts | 3 + server/platform/libsql.ts | 3 + server/platform/node.ts | 3 + .../licensing-admin.integration.test.ts | 70 +++++++++-- server/routes/licensing-admin.ts | 89 +++++++++----- server/services/licensing-cloud.ts | 13 ++ src/components/billing/PairingModal.tsx | 21 ++-- src/i18n/locales/en.json | 2 + src/i18n/locales/zh.json | 2 + 17 files changed, 360 insertions(+), 114 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index de0c70ff..41a3e7b2 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -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= diff --git a/package.json b/package.json index 7ed7c9ca..06b63cee 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7de2fdc..ef0e6f8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/licensing/cloud-event-token.ts b/server/licensing/cloud-event-token.ts index 0801ddf9..430483d1 100644 --- a/server/licensing/cloud-event-token.ts +++ b/server/licensing/cloud-event-token.ts @@ -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 } diff --git a/server/licensing/public-keys.test.ts b/server/licensing/public-keys.test.ts index 31f81f1a..1f666ce6 100644 --- a/server/licensing/public-keys.test.ts +++ b/server/licensing/public-keys.test.ts @@ -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([]) + }) }) diff --git a/server/licensing/public-keys.ts b/server/licensing/public-keys.ts index a6de71a2..e6c2cacd 100644 --- a/server/licensing/public-keys.ts +++ b/server/licensing/public-keys.ts @@ -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] +} diff --git a/server/licensing/verify.test.ts b/server/licensing/verify.test.ts index 832f03a2..4e40bdc2 100644 --- a/server/licensing/verify.test.ts +++ b/server/licensing/verify.test.ts @@ -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', + }) + }) +}) diff --git a/server/licensing/verify.ts b/server/licensing/verify.ts index 95249bc4..278dbf63 100644 --- a/server/licensing/verify.ts +++ b/server/licensing/verify.ts @@ -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(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(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 } } } diff --git a/server/platform/cloudflare.ts b/server/platform/cloudflare.ts index d034ce81..68568f52 100644 --- a/server/platform/cloudflare.ts +++ b/server/platform/cloudflare.ts @@ -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) { diff --git a/server/platform/libsql.ts b/server/platform/libsql.ts index c1361c76..402d3d47 100644 --- a/server/platform/libsql.ts +++ b/server/platform/libsql.ts @@ -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 { await migrate(db, { migrationsFolder }) + registerEnvPublicKeys(envRecord.ZPAN_LICENSE_PUBLIC_KEYS ?? process.env.ZPAN_LICENSE_PUBLIC_KEYS) + return { db, getEnv(key: string) { diff --git a/server/platform/node.ts b/server/platform/node.ts index 9392e2d5..b1291597 100644 --- a/server/platform/node.ts +++ b/server/platform/node.ts @@ -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) { diff --git a/server/routes/licensing-admin.integration.test.ts b/server/routes/licensing-admin.integration.test.ts index 03bd0e84..82806b7c 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/routes/licensing-admin.integration.test.ts @@ -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', () => { diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index eca3ecc5..3ef9f0c5 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -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 { + 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() .use(requireAdmin) @@ -58,38 +78,51 @@ const app = new Hono() 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() 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 }) }) diff --git a/server/services/licensing-cloud.ts b/server/services/licensing-cloud.ts index 9d522c3c..08d63bb1 100644 --- a/server/services/licensing-cloud.ts +++ b/server/services/licensing-cloud.ts @@ -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 { + 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(data: unknown): T { if (data && typeof data === 'object' && 'data' in data) return data.data as T return data as T diff --git a/src/components/billing/PairingModal.tsx b/src/components/billing/PairingModal.tsx index a809f937..4aa2b4f2 100644 --- a/src/components/billing/PairingModal.tsx +++ b/src/components/billing/PairingModal.tsx @@ -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 ( @@ -179,9 +180,13 @@ export function PairingModal({ open, onOpenChange }: PairingModalProps) { {state === 'denied' &&

{t('settings.billing.pairing.denied')}

} - {(state === 'expired' || state === 'error') && ( -

{t('settings.billing.pairing.expired')}

+ {state === 'expired' &&

{t('settings.billing.pairing.expired')}

} + + {state === 'certError' && ( +

{t('settings.billing.pairing.certError')}

)} + + {state === 'error' &&

{t('settings.billing.pairing.error')}

} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc9823d3..060fe5c1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cc2b5785..dbc199b6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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",