From 1997655875959399a7218ee0b5d7b19a8811968a Mon Sep 17 00:00:00 2001 From: imbytecat Date: Tue, 10 Mar 2026 16:20:49 +0800 Subject: [PATCH] feat(server): persist platform public key and enrich OpenAPI docs --- apps/server/src/routes/api/$.ts | 3 +- .../server/api/contracts/config.contract.ts | 42 ++++++++++-- .../server/api/contracts/crypto.contract.ts | 68 +++++++++---------- .../src/server/api/routers/config.router.ts | 16 ++++- .../src/server/api/routers/crypto.router.ts | 10 ++- apps/server/src/server/db/schema/ux-config.ts | 1 + apps/server/src/server/ux-config.ts | 12 ++++ 7 files changed, 106 insertions(+), 46 deletions(-) diff --git a/apps/server/src/routes/api/$.ts b/apps/server/src/routes/api/$.ts index 3b493eb..fda1675 100644 --- a/apps/server/src/routes/api/$.ts +++ b/apps/server/src/routes/api/$.ts @@ -16,7 +16,8 @@ const handler = new OpenAPIHandler(router, { info: { title: name, version, - description: 'UX 授权服务 OpenAPI 文档:设备授权、任务解密、摘要加密与报告签名打包接口。', + description: + 'UX 授权服务 OpenAPI 文档。该服务用于工具箱侧本地身份初始化与密码学能力调用,覆盖设备授权密文生成、任务二维码解密、摘要信息加密、报告签名打包等流程。\n\n推荐调用顺序:\n1) 写入 licence 与 OpenPGP 私钥;\n2) 读取本机身份状态进行前置校验;\n3) 执行加密/解密与签名接口。\n\n说明:除文件下载接口外,返回体均为 JSON;字段示例已提供,便于联调和 Mock。', }, }, docsPath: '/docs', diff --git a/apps/server/src/server/api/contracts/config.contract.ts b/apps/server/src/server/api/contracts/config.contract.ts index 9d54991..9a5513d 100644 --- a/apps/server/src/server/api/contracts/config.contract.ts +++ b/apps/server/src/server/api/contracts/config.contract.ts @@ -5,18 +5,26 @@ const configOutput = z .object({ licence: z.string().nullable().describe('当前本地 licence,未设置时为 null'), fingerprint: z.string().describe('UX 本机计算得到的设备特征码(SHA-256)'), + platformPublicKey: z.string().nullable().describe('本地持久化的平台公钥(Base64 编码 SPKI DER),未设置时为 null'), + hasPlatformPublicKey: z.boolean().describe('是否已配置平台公钥'), hasPgpPrivateKey: z.boolean().describe('是否已配置 OpenPGP 私钥'), }) + .describe('本地身份配置快照,用于判断设备授权初始化是否完成') .meta({ examples: [ { licence: 'LIC-8F2A-XXXX', fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', + platformPublicKey: + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzDlZvMDVaL+fjl05Hi182JOAUAaN4gh9rOF+1NhKfO4J6e0HLy8lBuylp3A4xoTiyUejNm22h0dqAgDSPnY/xZR76POFTD1soHr2LaFCN8JAbQ96P8gE7wC9qpoTssVvIVRH7QbVd260J6eD0Szwcx9cg591RSN69pMpe5IVRi8T99Hhql6/wnZHORPr18eESLOY93jRskLzc0q18r68RRoTJiQf+9YC8ub5iKp7rCjVnPi1UbIYmXmL08tk5mksYA0NqWQAa1ofKxx/9tQtB9uTjhTxuTu94XU9jlGU87qaHZs+kpqa8CAbYYJFbSP1xHwoZzpU2jpw2aF22HBYxwIDAQAB', + hasPlatformPublicKey: true, hasPgpPrivateKey: true, }, { licence: null, fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', + platformPublicKey: null, + hasPlatformPublicKey: false, hasPgpPrivateKey: false, }, ], @@ -29,10 +37,10 @@ export const get = oc operationId: 'configGet', summary: '读取本机身份配置', description: - '返回 UX 本地持久化的 licence、本机设备特征码(fingerprint)以及 OpenPGP 私钥配置状态。工具箱端可据此判断是否已完成本地身份初始化。', + '查询 UX 当前本地身份配置状态。\n\n典型用途:页面初始化时检测授权状态、加密前检查平台公钥、签名前检查私钥是否就绪。\n\n返回内容:\n- licence:当前持久化授权码,未设置时为 null;\n- fingerprint:设备特征码(本机自动计算);\n- platformPublicKey:本地平台公钥(用于验签或加密前核对);\n- hasPlatformPublicKey:是否已写入平台公钥;\n- hasPgpPrivateKey:是否已写入 OpenPGP 私钥。', tags: ['Config'], }) - .input(z.object({})) + .input(z.object({}).describe('空请求体,仅触发读取当前配置')) .output(configOutput) export const setLicence = oc @@ -42,7 +50,7 @@ export const setLicence = oc operationId: 'configSetLicence', summary: '写入本地 licence', description: - '写入或更新本机持久化的 licence。设备特征码(fingerprint)始终由 UX 本机自动计算,无需外部传入。此接口应在设备授权流程前调用。', + '写入或更新本机持久化 licence。\n\n调用时机:设备首次激活、授权码变更、授权修复。\n\n约束与行为:\n- 仅接收 licence 文本;\n- fingerprint 由本机自动计算,不允许外部覆盖;\n- 成功后返回最新配置快照,便于前端立即刷新授权状态。', tags: ['Config'], }) .input( @@ -63,7 +71,7 @@ export const setPgpPrivateKey = oc operationId: 'configSetPgpPrivateKey', summary: '写入本地 OpenPGP 私钥', description: - '写入或更新本机持久化的 OpenPGP 私钥(ASCII armored 格式),用于报告签名。私钥与设备绑定,调用报告签名接口时 UX 自动读取,无需每次传入。', + '写入或更新本机持久化 OpenPGP 私钥(ASCII armored)。\n\n调用时机:首次导入签名私钥、私钥轮换。\n\n约束与行为:\n- 仅接收 ASCII armored 私钥文本;\n- 私钥保存在本地,后续报告签名接口会自动读取;\n- 成功后返回最新配置快照,可用于确认 hasPgpPrivateKey 状态。', tags: ['Config'], }) .input( @@ -80,3 +88,29 @@ export const setPgpPrivateKey = oc }), ) .output(configOutput) + +export const setPlatformPublicKey = oc + .route({ + method: 'POST', + path: '/config/set-platform-public-key', + operationId: 'configSetPlatformPublicKey', + summary: '写入本地平台公钥', + description: + '写入或更新本机持久化平台公钥(Base64 编码 SPKI DER)。\n\n调用时机:设备授权初始化、平台公钥轮换。\n\n约束与行为:\n- 仅接收平台 RSA 公钥文本;\n- 公钥保存在本地,设备授权密文接口会自动读取,无需每次传参;\n- 成功后返回最新配置快照,可用于确认 hasPlatformPublicKey 状态。', + tags: ['Config'], + }) + .input( + z + .object({ + platformPublicKey: z.string().min(1).describe('平台公钥(Base64 编码 SPKI DER)'), + }) + .meta({ + examples: [ + { + platformPublicKey: + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzDlZvMDVaL+fjl05Hi182JOAUAaN4gh9rOF+1NhKfO4J6e0HLy8lBuylp3A4xoTiyUejNm22h0dqAgDSPnY/xZR76POFTD1soHr2LaFCN8JAbQ96P8gE7wC9qpoTssVvIVRH7QbVd260J6eD0Szwcx9cg591RSN69pMpe5IVRi8T99Hhql6/wnZHORPr18eESLOY93jRskLzc0q18r68RRoTJiQf+9YC8ub5iKp7rCjVnPi1UbIYmXmL08tk5mksYA0NqWQAa1ofKxx/9tQtB9uTjhTxuTu94XU9jlGU87qaHZs+kpqa8CAbYYJFbSP1xHwoZzpU2jpw2aF22HBYxwIDAQAB', + }, + ], + }), + ) + .output(configOutput) diff --git a/apps/server/src/server/api/contracts/crypto.contract.ts b/apps/server/src/server/api/contracts/crypto.contract.ts index ca00401..b9d0180 100644 --- a/apps/server/src/server/api/contracts/crypto.contract.ts +++ b/apps/server/src/server/api/contracts/crypto.contract.ts @@ -8,28 +8,16 @@ export const encryptDeviceInfo = oc operationId: 'encryptDeviceInfo', summary: '生成设备授权二维码密文', description: - '将本机 licence 与 fingerprint 组装为 JSON,使用平台 RSA 公钥(RSA-OAEP + SHA-256)加密后返回 Base64 密文,供工具箱生成设备授权二维码。参见《工具箱端 - 设备授权二维码生成指南》。', + '生成设备授权流程所需的二维码密文。\n\n处理流程:\n- 读取本机 licence、fingerprint 与本地持久化的平台公钥;\n- 组装为授权载荷 JSON;\n- 使用平台公钥执行 RSA-OAEP(SHA-256) 加密;\n- 返回 Base64 密文供前端生成二维码。\n\n适用场景:设备授权申请、重新授权。\n\n前置条件:需先调用 config.setPlatformPublicKey 写入平台公钥。', tags: ['Crypto'], }) - .input( - z - .object({ - platformPublicKey: z.string().min(1).describe('平台公钥(Base64,SPKI DER)'), - }) - .meta({ - examples: [ - { - platformPublicKey: - 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzDlZvMDVaL+fjl05Hi182JOAUAaN4gh9rOF+1NhKfO4J6e0HLy8lBuylp3A4xoTiyUejNm22h0dqAgDSPnY/xZR76POFTD1soHr2LaFCN8JAbQ96P8gE7wC9qpoTssVvIVRH7QbVd260J6eD0Szwcx9cg591RSN69pMpe5IVRi8T99Hhql6/wnZHORPr18eESLOY93jRskLzc0q18r68RRoTJiQf+9YC8ub5iKp7rCjVnPi1UbIYmXmL08tk5mksYA0NqWQAa1ofKxx/9tQtB9uTjhTxuTu94XU9jlGU87qaHZs+kpqa8CAbYYJFbSP1xHwoZzpU2jpw2aF22HBYxwIDAQAB', - }, - ], - }), - ) + .input(z.object({}).describe('空请求体。平台公钥由本地配置自动读取')) .output( z .object({ - encrypted: z.string().describe('Base64 密文(用于设备授权二维码)'), + encrypted: z.string().describe('Base64 密文(可直接用于设备授权二维码内容)'), }) + .describe('设备授权密文生成结果') .meta({ examples: [ { @@ -46,7 +34,7 @@ export const decryptTask = oc operationId: 'decryptTask', summary: '解密任务二维码数据', description: - '使用本机 licence 与 fingerprint 派生 AES-256-GCM 密钥(SHA-256),解密 App 任务二维码中的 Base64 密文,返回任务信息明文。参见《工具箱端 - 任务二维码解密指南》。', + '解密 App 下发的任务二维码密文。\n\n处理流程:\n- 基于本机 licence + fingerprint 派生 AES-256-GCM 密钥;\n- 对二维码中的 Base64 密文进行解密;\n- 返回任务明文 JSON 字符串。\n\n适用场景:扫码接收任务后解析任务详情。', tags: ['Crypto'], }) .input( @@ -54,6 +42,7 @@ export const decryptTask = oc .object({ encryptedData: z.string().min(1).describe('Base64 编码的 AES-256-GCM 密文(来自任务二维码扫描结果)'), }) + .describe('任务二维码解密请求') .meta({ examples: [ { @@ -65,8 +54,9 @@ export const decryptTask = oc .output( z .object({ - decrypted: z.string().describe('解密后的任务信息 JSON 字符串'), + decrypted: z.string().describe('解密后的任务信息 JSON 字符串(可进一步反序列化)'), }) + .describe('任务二维码解密结果') .meta({ examples: [ { @@ -84,15 +74,16 @@ export const encryptSummary = oc operationId: 'encryptSummary', summary: '加密摘要信息', description: - '使用本机 licence 与 fingerprint 通过 HKDF-SHA256 派生密钥,以 AES-256-GCM 加密检查摘要明文并返回 Base64 密文,供工具箱生成摘要信息二维码。参见《工具箱端 - 摘要信息二维码生成指南》。', + '加密检查摘要信息并产出二维码密文。\n\n处理流程:\n- 使用 licence + fingerprint 结合 taskId(salt) 通过 HKDF-SHA256 派生密钥;\n- 使用 AES-256-GCM 加密摘要明文;\n- 返回 Base64 密文用于摘要二维码生成。\n\n适用场景:任务执行后提交摘要信息。', tags: ['Crypto'], }) .input( z .object({ - salt: z.string().min(1).describe('HKDF salt(即 taskId,从任务二维码中获取)'), - plaintext: z.string().min(1).describe('待加密的摘要信息 JSON 明文'), + salt: z.string().min(1).describe('HKDF salt(通常为 taskId,需与任务上下文一致)'), + plaintext: z.string().min(1).describe('待加密的摘要信息 JSON 明文字符串'), }) + .describe('摘要信息加密请求') .meta({ examples: [ { @@ -106,8 +97,9 @@ export const encryptSummary = oc .output( z .object({ - encrypted: z.string().describe('Base64 密文(用于摘要信息二维码)'), + encrypted: z.string().describe('Base64 密文(用于摘要信息二维码内容)'), }) + .describe('摘要信息加密结果') .meta({ examples: [ { @@ -124,24 +116,26 @@ export const signAndPackReport = oc operationId: 'signAndPackReport', summary: '签名并打包检查报告', description: - '上传包含 summary.json 的原始报告 ZIP,UX 自动从 ZIP 中提取 summary.json,使用本地存储的 licence/fingerprint 计算设备签名(HKDF + HMAC-SHA256),并使用本地 OpenPGP 私钥生成分离式签名。返回包含 summary.json(含 deviceSignature)、META-INF/manifest.json、META-INF/signature.asc 的签名报告 ZIP。参见《工具箱端 - 报告加密与签名生成指南》。', + '对原始报告执行设备签名与 OpenPGP 签名并重新打包。\n\n处理流程:\n- 解析上传 ZIP 并提取 summary.json;\n- 用 licence/fingerprint 计算 deviceSignature(HKDF + HMAC-SHA256) 并回写 summary.json;\n- 生成 META-INF/manifest.json;\n- 使用本地 OpenPGP 私钥生成 detached signature(`META-INF/signature.asc`);\n- 返回签名后 ZIP。\n\n适用场景:检查结果归档、可追溯签名分发。', tags: ['Crypto', 'Report'], }) .input( - z.object({ - rawZip: z - .file() - .mime(['application/zip', 'application/x-zip-compressed']) - .describe( - '原始报告 ZIP 文件(必须包含 summary.json,以及 assets.json、vulnerabilities.json、weakPasswords.json、漏洞评估报告.html 等报告文件)', - ), - outputFileName: z - .string() - .min(1) - .optional() - .describe('返回 ZIP 文件名(可选,默认 signed-report.zip)') - .meta({ examples: ['signed-report.zip'] }), - }), + z + .object({ + rawZip: z + .file() + .mime(['application/zip', 'application/x-zip-compressed']) + .describe( + '原始报告 ZIP 文件(必须包含 summary.json,以及 assets.json、vulnerabilities.json、weakPasswords.json、漏洞评估报告.html 等报告文件)', + ), + outputFileName: z + .string() + .min(1) + .optional() + .describe('返回 ZIP 文件名(可选,默认 signed-report.zip)') + .meta({ examples: ['signed-report.zip'] }), + }) + .describe('报告签名与打包请求'), ) .output( z diff --git a/apps/server/src/server/api/routers/config.router.ts b/apps/server/src/server/api/routers/config.router.ts index 464e586..8f1a474 100644 --- a/apps/server/src/server/api/routers/config.router.ts +++ b/apps/server/src/server/api/routers/config.router.ts @@ -1,12 +1,19 @@ import { validatePgpPrivateKey } from '@furtherverse/crypto' import { ORPCError } from '@orpc/server' -import { ensureUxConfig, setUxLicence, setUxPgpPrivateKey } from '@/server/ux-config' +import { ensureUxConfig, setUxLicence, setUxPgpPrivateKey, setUxPlatformPublicKey } from '@/server/ux-config' import { db } from '../middlewares' import { os } from '../server' -const toConfigOutput = (config: { licence: string | null; fingerprint: string; pgpPrivateKey: string | null }) => ({ +const toConfigOutput = (config: { + licence: string | null + fingerprint: string + platformPublicKey: string | null + pgpPrivateKey: string | null +}) => ({ licence: config.licence, fingerprint: config.fingerprint, + platformPublicKey: config.platformPublicKey, + hasPlatformPublicKey: config.platformPublicKey != null, hasPgpPrivateKey: config.pgpPrivateKey != null, }) @@ -30,3 +37,8 @@ export const setPgpPrivateKey = os.config.setPgpPrivateKey.use(db).handler(async const config = await setUxPgpPrivateKey(context.db, input.pgpPrivateKey) return toConfigOutput(config) }) + +export const setPlatformPublicKey = os.config.setPlatformPublicKey.use(db).handler(async ({ context, input }) => { + const config = await setUxPlatformPublicKey(context.db, input.platformPublicKey) + return toConfigOutput(config) +}) diff --git a/apps/server/src/server/api/routers/crypto.router.ts b/apps/server/src/server/api/routers/crypto.router.ts index 900ea28..ab082d0 100644 --- a/apps/server/src/server/api/routers/crypto.router.ts +++ b/apps/server/src/server/api/routers/crypto.router.ts @@ -53,15 +53,21 @@ const requireIdentity = async (dbInstance: Parameters[0]) => return config as typeof config & { licence: string } } -export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => { +export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context }) => { const config = await requireIdentity(context.db) + if (!config.platformPublicKey) { + throw new ORPCError('PRECONDITION_FAILED', { + message: 'Platform public key is not configured. Call config.setPlatformPublicKey first.', + }) + } + const deviceInfoJson = JSON.stringify({ licence: config.licence, fingerprint: config.fingerprint, }) - const encrypted = rsaOaepEncrypt(deviceInfoJson, input.platformPublicKey) + const encrypted = rsaOaepEncrypt(deviceInfoJson, config.platformPublicKey) return { encrypted } }) diff --git a/apps/server/src/server/db/schema/ux-config.ts b/apps/server/src/server/db/schema/ux-config.ts index 6128db5..b17d8c6 100644 --- a/apps/server/src/server/db/schema/ux-config.ts +++ b/apps/server/src/server/db/schema/ux-config.ts @@ -6,5 +6,6 @@ export const uxConfigTable = sqliteTable('ux_config', { singletonKey: text('singleton_key').notNull().unique().default('default'), licence: text('licence'), fingerprint: text('fingerprint').notNull(), + platformPublicKey: text('platform_public_key'), pgpPrivateKey: text('pgp_private_key'), }) diff --git a/apps/server/src/server/ux-config.ts b/apps/server/src/server/ux-config.ts index ee56426..c48144a 100644 --- a/apps/server/src/server/ux-config.ts +++ b/apps/server/src/server/ux-config.ts @@ -54,3 +54,15 @@ export const setUxPgpPrivateKey = async (db: DB, pgpPrivateKey: string) => { return rows[0] as (typeof rows)[number] } + +export const setUxPlatformPublicKey = async (db: DB, platformPublicKey: string) => { + const config = await ensureUxConfig(db) + + const rows = await db + .update(uxConfigTable) + .set({ platformPublicKey }) + .where(eq(uxConfigTable.id, config.id)) + .returning() + + return rows[0] as (typeof rows)[number] +}