From 403eec3e12260f373ff539768eb2b049ce594c03 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Thu, 19 Mar 2026 16:16:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E9=85=8D=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=8E=A5=E5=85=A5=20licence=20=E9=AA=8C=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/api/contracts/config.contract.ts | 39 +++++++++++----- .../src/server/api/routers/config.router.ts | 46 +++++++++++++++++-- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/apps/server/src/server/api/contracts/config.contract.ts b/apps/server/src/server/api/contracts/config.contract.ts index e521aa9..02d83cb 100644 --- a/apps/server/src/server/api/contracts/config.contract.ts +++ b/apps/server/src/server/api/contracts/config.contract.ts @@ -1,9 +1,18 @@ import { oc } from '@orpc/contract' import { z } from 'zod' +import { licenceEnvelopeSchema } from '@/server/licence' + +const licenceOutput = z + .object({ + licenceId: z.string().describe('验签通过后的 licence 标识'), + expireTime: z.string().describe('授权到期日,格式为 YYYY-MM-DD'), + isExpired: z.boolean().describe('当前 licence 是否已过期(按 UTC 自然日计算)'), + }) + .describe('当前已安装 licence 的验证后元数据') const configOutput = z .object({ - licence: z.string().nullable().describe('当前本地 licence,未设置时为 null'), + licence: licenceOutput.nullable().describe('当前本地已验证 licence 的元数据,未设置时为 null'), fingerprint: z.string().describe('UX 本机计算得到的设备特征码(SHA-256)'), hasPlatformPublicKey: z.boolean().describe('是否已配置平台公钥'), hasPgpPrivateKey: z.boolean().describe('是否已配置 OpenPGP 私钥'), @@ -12,7 +21,11 @@ const configOutput = z .meta({ examples: [ { - licence: 'LIC-8F2A-XXXX', + licence: { + licenceId: 'LIC-20260319-0025', + expireTime: '2027-03-19', + isExpired: false, + }, fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', hasPlatformPublicKey: true, hasPgpPrivateKey: true, @@ -33,7 +46,7 @@ export const get = oc operationId: 'configGet', summary: '读取本机身份配置', description: - '查询 UX 当前本地身份配置状态。\n\n典型用途:页面初始化时检测授权状态、加密前检查平台公钥、签名前检查私钥是否就绪。\n\n返回内容:\n- licence:当前持久化授权码,未设置时为 null;\n- fingerprint:设备特征码(本机自动计算);\n- hasPlatformPublicKey:是否已写入平台公钥;\n- hasPgpPrivateKey:是否已写入 OpenPGP 私钥。', + '查询 UX 当前本地身份配置状态。\n\n典型用途:页面初始化时检测授权状态、验签前检查平台公钥、签名前检查私钥是否就绪。\n\n返回内容:\n- licence:当前已验证 licence 的元数据,未设置时为 null;\n- fingerprint:设备特征码(本机自动计算);\n- hasPlatformPublicKey:是否已写入平台公钥;\n- hasPgpPrivateKey:是否已写入 OpenPGP 私钥。', tags: ['Config'], }) .input(z.object({}).describe('空请求体,仅触发读取当前配置')) @@ -46,17 +59,19 @@ export const setLicence = oc operationId: 'configSetLicence', summary: '写入本地 licence', description: - '写入或更新本机持久化 licence。\n\n调用时机:设备首次激活、授权码变更、授权修复。\n\n约束与行为:\n- 仅接收 licence 文本;\n- fingerprint 由本机自动计算,不允许外部覆盖;\n- 成功后返回最新配置快照,便于前端立即刷新授权状态。', + '写入或更新本机持久化 licence。\n\n调用时机:设备首次激活、授权码变更、授权修复。\n\n约束与行为:\n- 接收 `.lic` 文件内容对应的 JSON 信封,而不是文件上传;\n- 使用已配置的平台公钥对 payload 原始字符串做 SHA256withRSA 验签;\n- 仅在验签通过且 expire_time 未过期时持久化;\n- fingerprint 由本机自动计算,不允许外部覆盖;\n- 成功后返回最新配置快照,便于前端立即刷新授权状态。', tags: ['Config'], }) .input( - z - .object({ - licence: z.string().min(1).describe('本地持久化的 licence'), - }) - .meta({ - examples: [{ licence: 'LIC-8F2A-XXXX' }], - }), + licenceEnvelopeSchema.meta({ + examples: [ + { + payload: 'eyJsaWNlbmNlX2lkIjoiTElDLTIwMjYwMzE5LTAwMjUiLCJleHBpcmVfdGltZSI6IjIwMjctMDMtMTkifQ==', + signature: + 'aLd+wwpz1W5AS0jgE/IstSNjCAQ5estQYIMqeLXRWMIsnKxjZpCvC8O5q/G5LEBBLJXnbTk8N6IMTUx295nf2HQYlXNtJkWiBeUXQ6/uzs0RbhCeRAWK2Hx4kSsmiEv4AHGLb4ozI2XekTc+40+ApJQYqaWbDu/NU99TmDm3/da1VkKpQxH60BhSQVwBtU67w9Vp3SpWm8y1faQ7ci5WDtJf1JZaS70kPXoGeA5018rPeMFlEzUp10yDlGW6RcrT7Dm+r7zFyrFznLK+evBEvTf9mMGWwZZP3q9vJtC/wFt1t5zNHdkb27cTwc9yyqGMWdelXQAQDnoisn2Jzi06KA==', + }, + ], + }), ) .output(configOutput) @@ -92,7 +107,7 @@ export const setPlatformPublicKey = oc operationId: 'configSetPlatformPublicKey', summary: '写入本地平台公钥', description: - '写入或更新本机持久化平台公钥(Base64 编码 SPKI DER)。\n\n调用时机:设备授权初始化、平台公钥轮换。\n\n约束与行为:\n- 仅接收平台 RSA 公钥文本;\n- 公钥保存在本地,设备授权密文接口会自动读取,无需每次传参;\n- 成功后返回最新配置快照,可用于确认 hasPlatformPublicKey 状态。', + '写入或更新本机持久化平台公钥(Base64 编码 SPKI DER)。\n\n调用时机:设备授权初始化、平台公钥轮换。\n\n约束与行为:\n- 仅接收可解析的平台 RSA 公钥文本;\n- 公钥保存在本地,设备授权密文接口和 licence 验签都会自动读取,无需每次传参;\n- 若平台公钥发生变化,已安装 licence 会被清空,需要重新安装已签名 licence;\n- 成功后返回最新配置快照,可用于确认 hasPlatformPublicKey 状态。', tags: ['Config'], }) .input( diff --git a/apps/server/src/server/api/routers/config.router.ts b/apps/server/src/server/api/routers/config.router.ts index ca0c30f..606bef1 100644 --- a/apps/server/src/server/api/routers/config.router.ts +++ b/apps/server/src/server/api/routers/config.router.ts @@ -1,16 +1,25 @@ -import { validatePgpPrivateKey } from '@furtherverse/crypto' +import { validatePgpPrivateKey, validateRsaPublicKey } from '@furtherverse/crypto' import { ORPCError } from '@orpc/server' +import { isLicenceExpired, verifyAndDecodeLicenceEnvelope } from '@/server/licence' import { ensureUxConfig, setUxLicence, setUxPgpPrivateKey, setUxPlatformPublicKey } from '@/server/ux-config' import { db } from '../middlewares' import { os } from '../server' const toConfigOutput = (config: { - licence: string | null + licenceId: string | null + licenceExpireTime: string | null fingerprint: string platformPublicKey: string | null pgpPrivateKey: string | null }) => ({ - licence: config.licence, + licence: + config.licenceId && config.licenceExpireTime + ? { + licenceId: config.licenceId, + expireTime: config.licenceExpireTime, + isExpired: isLicenceExpired(config.licenceExpireTime), + } + : null, fingerprint: config.fingerprint, hasPlatformPublicKey: config.platformPublicKey != null, hasPgpPrivateKey: config.pgpPrivateKey != null, @@ -22,7 +31,28 @@ export const get = os.config.get.use(db).handler(async ({ context }) => { }) export const setLicence = os.config.setLicence.use(db).handler(async ({ context, input }) => { - const config = await setUxLicence(context.db, input.licence) + const currentConfig = await ensureUxConfig(context.db) + + if (!currentConfig.platformPublicKey) { + throw new ORPCError('PRECONDITION_FAILED', { + message: 'Platform public key is not configured. Call config.setPlatformPublicKey first.', + }) + } + + const payload = verifyAndDecodeLicenceEnvelope(input, currentConfig.platformPublicKey) + if (isLicenceExpired(payload.expire_time)) { + throw new ORPCError('BAD_REQUEST', { + message: 'licence has expired', + }) + } + + const config = await setUxLicence(context.db, { + payload: input.payload, + signature: input.signature, + licenceId: payload.licence_id, + expireTime: payload.expire_time, + }) + return toConfigOutput(config) }) @@ -38,6 +68,14 @@ export const setPgpPrivateKey = os.config.setPgpPrivateKey.use(db).handler(async }) export const setPlatformPublicKey = os.config.setPlatformPublicKey.use(db).handler(async ({ context, input }) => { + try { + validateRsaPublicKey(input.platformPublicKey) + } catch (error) { + throw new ORPCError('BAD_REQUEST', { + message: `Invalid platform public key: ${error instanceof Error ? error.message : 'unable to parse'}`, + }) + } + const config = await setUxPlatformPublicKey(context.db, input.platformPublicKey) return toConfigOutput(config) })