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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
export const get = os.config.get.use(db).handler(async ({ context }) => {
|
||||
const config = await ensureUxConfig(context.db)
|
||||
return {
|
||||
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 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)
|
||||
})
|
||||
|
||||
@@ -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,36 +204,41 @@ 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) {
|
||||
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) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user