feat(server): persist platform public key and enrich OpenAPI docs

This commit is contained in:
2026-03-10 16:20:49 +08:00
parent 9a2bd5c43a
commit 1997655875
7 changed files with 106 additions and 46 deletions

View File

@@ -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',

View File

@@ -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)

View File

@@ -8,28 +8,16 @@ export const encryptDeviceInfo = oc
operationId: 'encryptDeviceInfo',
summary: '生成设备授权二维码密文',
description:
'本机 licencefingerprint 组装为 JSON使用平台 RSA 公钥(RSA-OAEP + SHA-256)加密后返回 Base64 密文,供工具箱生成设备授权二维码。参见《工具箱端 - 设备授权二维码生成指南》。',
'生成设备授权流程所需的二维码密文。\n\n处理流程\n- 读取本机 licencefingerprint 与本地持久化的平台公钥;\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('平台公钥Base64SPKI 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 的原始报告 ZIPUX 自动从 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

View File

@@ -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)
})

View File

@@ -53,15 +53,21 @@ const requireIdentity = async (dbInstance: Parameters<typeof getUxConfig>[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 }
})

View File

@@ -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'),
})

View File

@@ -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]
}