diff --git a/apps/server/src/server/api/contracts/config.contract.ts b/apps/server/src/server/api/contracts/config.contract.ts index 30a8927..9d54991 100644 --- a/apps/server/src/server/api/contracts/config.contract.ts +++ b/apps/server/src/server/api/contracts/config.contract.ts @@ -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) diff --git a/apps/server/src/server/api/contracts/crypto.contract.ts b/apps/server/src/server/api/contracts/crypto.contract.ts index 4f5ae14..ca00401 100644 --- a/apps/server/src/server/api/contracts/crypto.contract.ts +++ b/apps/server/src/server/api/contracts/crypto.contract.ts @@ -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 的原始报告 ZIP,UX 自动从 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( diff --git a/apps/server/src/server/api/routers/config.router.ts b/apps/server/src/server/api/routers/config.router.ts index da2cb60..208d726 100644 --- a/apps/server/src/server/api/routers/config.router.ts +++ b/apps/server/src/server/api/routers/config.router.ts @@ -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) }) diff --git a/apps/server/src/server/api/routers/crypto.router.ts b/apps/server/src/server/api/routers/crypto.router.ts index 8a9c140..075ec39 100644 --- a/apps/server/src/server/api/routers/crypto.router.ts +++ b/apps/server/src/server/api/routers/crypto.router.ts @@ -98,24 +98,18 @@ const listSafeZipFiles = async (rawZip: JSZip): Promise => { 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[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 + 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 = { '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) diff --git a/apps/server/src/server/db/schema/ux-config.ts b/apps/server/src/server/db/schema/ux-config.ts index 42ad91a..6128db5 100644 --- a/apps/server/src/server/db/schema/ux-config.ts +++ b/apps/server/src/server/db/schema/ux-config.ts @@ -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'), }) diff --git a/apps/server/src/server/ux-config.ts b/apps/server/src/server/ux-config.ts index ffbe814..ee56426 100644 --- a/apps/server/src/server/ux-config.ts +++ b/apps/server/src/server/ux-config.ts @@ -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] +}