refactor(server): 简化 signAndPackReport 接口,PGP 私钥本地存储、summary.json 从 ZIP 提取

- DB schema 新增 pgpPrivateKey 字段
- 新增 config.setPgpPrivateKey 接口,私钥与设备绑定
- signAndPackReport 只需传 rawZip,signingContext 自动从 summary.json 派生
- configOutput 新增 hasPgpPrivateKey 字段
- 抽取 requireIdentity 减少重复校验代码
This commit is contained in:
2026-03-06 14:55:12 +08:00
parent ec41a4cfc7
commit 122dead202
6 changed files with 118 additions and 81 deletions

View File

@@ -5,16 +5,19 @@ const configOutput = z
.object({
licence: z.string().nullable().describe('当前本地 licence未设置时为 null'),
fingerprint: z.string().describe('UX 本机计算得到的设备特征码SHA-256'),
hasPgpPrivateKey: z.boolean().describe('是否已配置 OpenPGP 私钥'),
})
.meta({
examples: [
{
licence: 'LIC-8F2A-XXXX',
fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b',
hasPgpPrivateKey: true,
},
{
licence: null,
fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b',
hasPgpPrivateKey: false,
},
],
})
@@ -26,7 +29,7 @@ export const get = oc
operationId: 'configGet',
summary: '读取本机身份配置',
description:
'返回 UX 本地持久化的 licence 和本机计算的设备特征码fingerprint。工具箱端可据此判断是否已完成本地身份初始化。',
'返回 UX 本地持久化的 licence、本机设备特征码fingerprint以及 OpenPGP 私钥配置状态。工具箱端可据此判断是否已完成本地身份初始化。',
tags: ['Config'],
})
.input(z.object({}))
@@ -52,3 +55,28 @@ export const setLicence = oc
}),
)
.output(configOutput)
export const setPgpPrivateKey = oc
.route({
method: 'POST',
path: '/config/set-pgp-private-key',
operationId: 'configSetPgpPrivateKey',
summary: '写入本地 OpenPGP 私钥',
description:
'写入或更新本机持久化的 OpenPGP 私钥ASCII armored 格式),用于报告签名。私钥与设备绑定,调用报告签名接口时 UX 自动读取,无需每次传入。',
tags: ['Config'],
})
.input(
z
.object({
pgpPrivateKey: z.string().min(1).describe('OpenPGP 私钥ASCII armored 格式)'),
})
.meta({
examples: [
{
pgpPrivateKey: '-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxcMGBGd...\n-----END PGP PRIVATE KEY BLOCK-----',
},
],
}),
)
.output(configOutput)

View File

@@ -124,46 +124,23 @@ export const signAndPackReport = oc
operationId: 'signAndPackReport',
summary: '签名并打包检查报告',
description:
'对原始报告 ZIP 执行设备签名HKDF + HMAC-SHA256 OpenPGP 分离式签名,生成含 summary.json、META-INF/manifest.json、META-INF/signature.asc 的签名报告 ZIP 文件。参见《工具箱端 - 报告加密与签名生成指南》。',
'上传包含 summary.json 的原始报告 ZIPUX 自动从 ZIP 中提取 summary.json使用本地存储的 licence/fingerprint 计算设备签名HKDF + HMAC-SHA256,并使用本地 OpenPGP 私钥生成分离式签名。返回包含 summary.json(含 deviceSignature、META-INF/manifest.json、META-INF/signature.asc 的签名报告 ZIP。参见《工具箱端 - 报告加密与签名生成指南》。',
tags: ['Crypto', 'Report'],
})
.input(
z.object({
pgpPrivateKey: z
.string()
.min(1)
.describe('OpenPGP 私钥ASCII armored')
.meta({
examples: ['-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxcMGBGd...\n-----END PGP PRIVATE KEY BLOCK-----'],
}),
signingContext: z
.string()
.min(1)
.describe('签名上下文字符串(通常为 taskId + inspectionId 拼接)')
.meta({
examples: ['TASK-20260115-4875702286470691215417'],
}),
summaryJson: z
.string()
.min(1)
.describe('summary.json 的完整 JSON 文本(包含 orgId、checkId、taskId 等业务字段)')
.meta({
examples: [
'{"orgId":1173040813421105152,"checkId":702286470691215417,"taskId":"TASK-20260115-4875","summary":"检查摘要信息"}',
],
}),
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'] }),
rawZip: z
.file()
.mime(['application/zip', 'application/x-zip-compressed'])
.describe(
'原始报告 ZIP 文件multipart/form-data 字段,应包含 assets.json、vulnerabilities.json、weakPasswords.json、漏洞评估报告.html',
),
}),
)
.output(

View File

@@ -1,19 +1,24 @@
import { ensureUxConfig, setUxLicence } from '@/server/ux-config'
import { ensureUxConfig, setUxLicence, setUxPgpPrivateKey } from '@/server/ux-config'
import { db } from '../middlewares'
import { os } from '../server'
const toConfigOutput = (config: { licence: string | null; fingerprint: string; pgpPrivateKey: string | null }) => ({
licence: config.licence,
fingerprint: config.fingerprint,
hasPgpPrivateKey: config.pgpPrivateKey != null,
})
export const get = os.config.get.use(db).handler(async ({ context }) => {
const config = await ensureUxConfig(context.db)
return {
licence: config.licence,
fingerprint: config.fingerprint,
}
return toConfigOutput(config)
})
export const setLicence = os.config.setLicence.use(db).handler(async ({ context, input }) => {
const config = await setUxLicence(context.db, input.licence)
return {
licence: config.licence,
fingerprint: config.fingerprint,
}
return toConfigOutput(config)
})
export const setPgpPrivateKey = os.config.setPgpPrivateKey.use(db).handler(async ({ context, input }) => {
const config = await setUxPgpPrivateKey(context.db, input.pgpPrivateKey)
return toConfigOutput(config)
})

View File

@@ -98,24 +98,18 @@ const listSafeZipFiles = async (rawZip: JSZip): Promise<ZipFileItem[]> => {
return files
}
const parseSummaryJsonOrThrow = (summaryJson: string): string => {
try {
const parsed = JSON.parse(summaryJson)
return JSON.stringify(parsed)
} catch (error) {
throw new ORPCError('BAD_REQUEST', {
message: `summaryJson is not valid JSON: ${error instanceof Error ? error.message : 'unknown error'}`,
})
}
}
export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => {
const config = await getUxConfig(context.db)
const requireIdentity = async (dbInstance: Parameters<typeof getUxConfig>[0]) => {
const config = await getUxConfig(dbInstance)
if (!config || !config.licence) {
throw new ORPCError('PRECONDITION_FAILED', {
message: 'Local identity is not initialized. Call config.get and then config.setLicence first.',
})
}
return config as typeof config & { licence: string }
}
export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => {
const config = await requireIdentity(context.db)
const deviceInfoJson = JSON.stringify({
licence: config.licence,
@@ -127,12 +121,7 @@ export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(asy
})
export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ context, input }) => {
const config = await getUxConfig(context.db)
if (!config || !config.licence) {
throw new ORPCError('PRECONDITION_FAILED', {
message: 'Local identity is not initialized. Call config.get and then config.setLicence first.',
})
}
const config = await requireIdentity(context.db)
const key = sha256(config.licence + config.fingerprint)
const decrypted = aesGcmDecrypt(input.encryptedData, key)
@@ -140,12 +129,7 @@ export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ contex
})
export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({ context, input }) => {
const config = await getUxConfig(context.db)
if (!config || !config.licence) {
throw new ORPCError('PRECONDITION_FAILED', {
message: 'Local identity is not initialized. Call config.get and then config.setLicence first.',
})
}
const config = await requireIdentity(context.db)
const ikm = config.licence + config.fingerprint
const aesKey = hkdfSha256(ikm, input.salt, 'inspection_report_encryption')
@@ -154,10 +138,11 @@ export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({
})
export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(async ({ context, input }) => {
const config = await getUxConfig(context.db)
if (!config || !config.licence) {
const config = await requireIdentity(context.db)
if (!config.pgpPrivateKey) {
throw new ORPCError('PRECONDITION_FAILED', {
message: 'Local identity is not initialized. Call config.get and then config.setLicence first.',
message: 'PGP private key is not configured. Call config.setPgpPrivateKey first.',
})
}
@@ -180,6 +165,34 @@ export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(asy
const zipFiles = await listSafeZipFiles(rawZip)
// Extract and parse summary.json from the ZIP
const summaryFile = zipFiles.find((f) => f.name === 'summary.json')
if (!summaryFile) {
throw new ORPCError('BAD_REQUEST', {
message: 'rawZip must contain a summary.json file',
})
}
let summaryPayload: Record<string, unknown>
try {
summaryPayload = JSON.parse(Buffer.from(summaryFile.bytes).toString('utf-8'))
} catch {
throw new ORPCError('BAD_REQUEST', {
message: 'summary.json in the ZIP is not valid JSON',
})
}
// Derive signingContext from summary.json fields
const taskId = String(summaryPayload.taskId ?? '')
const checkId = String(summaryPayload.checkId ?? summaryPayload.inspectionId ?? '')
if (!taskId) {
throw new ORPCError('BAD_REQUEST', {
message: 'summary.json must contain a taskId field',
})
}
const signingContext = `${taskId}${checkId}`
// Compute device signature
const ikm = config.licence + config.fingerprint
const signingKey = hkdfSha256(ikm, 'AUTH_V3_SALT', 'device_report_signature')
@@ -191,35 +204,40 @@ export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(asy
.sort((a, b) => a.name.localeCompare(b.name, 'en'))
const hashPayload = fileHashEntries.map((item) => `${item.name}:${item.hash}`).join('|')
const signPayload = `${input.signingContext}|${hashPayload}`
const signPayload = `${signingContext}|${hashPayload}`
const deviceSignature = hmacSha256Base64(signingKey, signPayload)
const normalizedSummaryJson = parseSummaryJsonOrThrow(input.summaryJson)
const summaryObject = {
// Build final summary.json with device signature and identity
const finalSummary = {
deviceSignature,
signingContext: input.signingContext,
payload: JSON.parse(normalizedSummaryJson),
signingContext,
licence: config.licence,
fingerprint: config.fingerprint,
payload: summaryPayload,
timestamp: Date.now(),
}
const summaryBytes = Buffer.from(JSON.stringify(summaryObject), 'utf-8')
const summaryBytes = Buffer.from(JSON.stringify(finalSummary), 'utf-8')
// Build manifest.json
const manifestFiles: Record<string, string> = {
'summary.json': sha256Hex(summaryBytes),
}
for (const item of fileHashEntries) {
manifestFiles[item.name] = item.hash
if (item.name !== 'summary.json') {
manifestFiles[item.name] = item.hash
}
}
const manifestObject = {
files: manifestFiles,
}
const manifestBytes = Buffer.from(JSON.stringify(manifestObject, null, 2), 'utf-8')
const signatureAsc = await pgpSignDetached(manifestBytes, input.pgpPrivateKey)
const manifestBytes = Buffer.from(JSON.stringify({ files: manifestFiles }, null, 2), 'utf-8')
const signatureAsc = await pgpSignDetached(manifestBytes, config.pgpPrivateKey)
// Pack signed ZIP
const signedZip = new JSZip()
signedZip.file('summary.json', summaryBytes)
for (const item of zipFiles) {
signedZip.file(item.name, item.bytes)
if (item.name !== 'summary.json') {
signedZip.file(item.name, item.bytes)
}
}
signedZip.file('META-INF/manifest.json', manifestBytes)
signedZip.file('META-INF/signature.asc', signatureAsc)

View File

@@ -6,4 +6,5 @@ export const uxConfigTable = sqliteTable('ux_config', {
singletonKey: text('singleton_key').notNull().unique().default('default'),
licence: text('licence'),
fingerprint: text('fingerprint').notNull(),
pgpPrivateKey: text('pgp_private_key'),
})

View File

@@ -46,3 +46,11 @@ export const setUxLicence = async (db: DB, licence: string) => {
return rows[0] as (typeof rows)[number]
}
export const setUxPgpPrivateKey = async (db: DB, pgpPrivateKey: string) => {
const config = await ensureUxConfig(db)
const rows = await db.update(uxConfigTable).set({ pgpPrivateKey }).where(eq(uxConfigTable.id, config.id)).returning()
return rows[0] as (typeof rows)[number]
}