Compare commits
2 Commits
46e2c94faf
...
060ddd8e12
| Author | SHA1 | Date | |
|---|---|---|---|
| 060ddd8e12 | |||
| b50d2eaf10 |
35
apps/server/src/server/api/contracts/config.contract.ts
Normal file
35
apps/server/src/server/api/contracts/config.contract.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const configOutput = z.object({
|
||||||
|
licence: z.string().nullable().describe('当前本地 licence,未设置时为 null'),
|
||||||
|
fingerprint: z.string().describe('UX 本机计算得到的 fingerprint'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const get = oc
|
||||||
|
.route({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/config/get',
|
||||||
|
operationId: 'configGet',
|
||||||
|
summary: '读取 UX 本地身份配置',
|
||||||
|
description: '返回 UX 当前存储的 licence 与本机 fingerprint。',
|
||||||
|
tags: ['Config'],
|
||||||
|
})
|
||||||
|
.input(z.object({}))
|
||||||
|
.output(configOutput)
|
||||||
|
|
||||||
|
export const setLicence = oc
|
||||||
|
.route({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/config/set-licence',
|
||||||
|
operationId: 'configSetLicence',
|
||||||
|
summary: '设置本地 licence',
|
||||||
|
description: '写入或更新 UX 本地 licence。fingerprint 始终由 UX 本机计算。',
|
||||||
|
tags: ['Config'],
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
licence: z.string().min(1).describe('本地持久化的 licence'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(configOutput)
|
||||||
@@ -6,14 +6,14 @@ export const encryptDeviceInfo = oc
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/crypto/encrypt-device-info',
|
path: '/crypto/encrypt-device-info',
|
||||||
operationId: 'encryptDeviceInfo',
|
operationId: 'encryptDeviceInfo',
|
||||||
summary: '生成设备授权密文',
|
summary: '加密设备信息',
|
||||||
description:
|
description:
|
||||||
'按 deviceId 查询已注册设备,使用设备记录中的 platformPublicKey 对 {licence, fingerprint} 做 RSA-OAEP 加密,返回 Base64 密文。',
|
'读取 UX 本地存储的 licence 与 fingerprint,组装为 UTF-8 JSON 字符串后,使用平台公钥执行 RSA-OAEP 加密并返回 Base64 密文。',
|
||||||
tags: ['Crypto'],
|
tags: ['Crypto'],
|
||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
deviceId: z.string().min(1).describe('设备 ID'),
|
platformPublicKey: z.string().min(1).describe('平台公钥(Base64,SPKI DER)'),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(
|
.output(
|
||||||
@@ -27,25 +27,19 @@ export const decryptTask = oc
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/crypto/decrypt-task',
|
path: '/crypto/decrypt-task',
|
||||||
operationId: 'decryptTask',
|
operationId: 'decryptTask',
|
||||||
summary: '解密任务二维码',
|
summary: '解密 AES-GCM 密文',
|
||||||
description:
|
description:
|
||||||
'按 deviceId 查询已注册设备,使用 UTF-8 编码下的 licence 与 fingerprint 直接拼接(无分隔符)后取 SHA256 作为 AES-256-GCM 密钥解密任务密文。',
|
'读取 UX 本地存储的 licence 与 fingerprint,UTF-8 直接拼接(无分隔符)后取 SHA256 作为 AES-256-GCM 密钥,解密 Base64 密文。',
|
||||||
tags: ['Crypto'],
|
tags: ['Crypto'],
|
||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
deviceId: z.string().min(1).describe('设备 ID'),
|
encryptedData: z.string().min(1).describe('Base64 密文'),
|
||||||
encryptedData: z.string().min(1).describe('任务二维码中的 Base64 密文'),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(
|
.output(
|
||||||
z.object({
|
z.object({
|
||||||
taskId: z.string().describe('任务 ID'),
|
decrypted: z.string().describe('解密后的 UTF-8 明文字符串'),
|
||||||
enterpriseId: z.string().describe('企业 ID'),
|
|
||||||
orgName: z.string().describe('单位名称'),
|
|
||||||
inspectionId: z.string().describe('检查 ID'),
|
|
||||||
inspectionPerson: z.string().describe('检查人'),
|
|
||||||
issuedAt: z.number().describe('任务发布时间戳(毫秒)'),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,22 +48,20 @@ export const encryptSummary = oc
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/crypto/encrypt-summary',
|
path: '/crypto/encrypt-summary',
|
||||||
operationId: 'encryptSummary',
|
operationId: 'encryptSummary',
|
||||||
summary: '加密摘要二维码内容',
|
summary: '加密文本内容',
|
||||||
description: '按 deviceId 查询已注册设备,使用 HKDF-SHA256 + AES-256-GCM 加密摘要信息,返回二维码 JSON 字符串。',
|
description:
|
||||||
|
'读取 UX 本地存储的 licence 与 fingerprint,使用 HKDF-SHA256 + AES-256-GCM 加密明文文本,返回 Base64 密文。',
|
||||||
tags: ['Crypto'],
|
tags: ['Crypto'],
|
||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
deviceId: z.string().min(1).describe('设备 ID'),
|
salt: z.string().min(1).describe('HKDF salt(例如 taskId)'),
|
||||||
taskId: z.string().min(1).describe('任务 ID'),
|
plaintext: z.string().min(1).describe('待加密明文'),
|
||||||
enterpriseId: z.string().min(1).describe('企业 ID'),
|
|
||||||
inspectionId: z.string().min(1).describe('检查 ID'),
|
|
||||||
summary: z.string().min(1).describe('摘要明文'),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(
|
.output(
|
||||||
z.object({
|
z.object({
|
||||||
qrContent: z.string().describe('二维码内容 JSON:{"taskId":"...","encrypted":"..."}'),
|
encrypted: z.string().describe('Base64 密文'),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,18 +70,17 @@ export const signAndPackReport = oc
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/crypto/sign-and-pack-report',
|
path: '/crypto/sign-and-pack-report',
|
||||||
operationId: 'signAndPackReport',
|
operationId: 'signAndPackReport',
|
||||||
summary: '签名并打包报告 ZIP',
|
summary: '签名并打包 ZIP',
|
||||||
description:
|
description:
|
||||||
'接收原始 ZIP(multipart/form-data 文件字段 rawZip),由 UX 生成 summary.json、manifest.json、signature.asc,并直接返回签名后 ZIP 二进制文件。',
|
'读取 UX 本地存储的 licence 与 fingerprint,结合签名材料处理原始 ZIP,生成 summary.json、META-INF/manifest.json、META-INF/signature.asc,并返回签名后 ZIP 二进制文件。',
|
||||||
tags: ['Crypto', 'Report'],
|
tags: ['Crypto', 'Report'],
|
||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
deviceId: z.string().min(1).describe('设备 ID'),
|
pgpPrivateKey: z.string().min(1).describe('OpenPGP 私钥(ASCII armored)'),
|
||||||
taskId: z.string().min(1).describe('任务 ID'),
|
signingContext: z.string().min(1).describe('签名上下文字符串(由调用方定义)'),
|
||||||
enterpriseId: z.string().min(1).describe('企业 ID'),
|
summaryJson: z.string().min(1).describe('summary.json 的完整 JSON 文本'),
|
||||||
inspectionId: z.string().min(1).describe('检查 ID'),
|
outputFileName: z.string().min(1).optional().describe('返回 ZIP 文件名(可选)'),
|
||||||
summary: z.string().min(1).describe('检查摘要明文'),
|
|
||||||
rawZip: z
|
rawZip: z
|
||||||
.file()
|
.file()
|
||||||
.mime(['application/zip', 'application/x-zip-compressed'])
|
.mime(['application/zip', 'application/x-zip-compressed'])
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { oc } from '@orpc/contract'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const deviceOutput = z.object({
|
|
||||||
id: z.string().describe('设备主键 ID'),
|
|
||||||
licence: z.string().describe('设备授权码 licence'),
|
|
||||||
fingerprint: z.string().describe('UX 计算并持久化的设备指纹'),
|
|
||||||
platformPublicKey: z.string().describe('平台公钥(Base64,SPKI DER)'),
|
|
||||||
pgpPublicKey: z.string().nullable().describe('设备 OpenPGP 公钥(ASCII armored)'),
|
|
||||||
createdAt: z.date().describe('记录创建时间'),
|
|
||||||
updatedAt: z.date().describe('记录更新时间'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const register = oc
|
|
||||||
.route({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/device/register',
|
|
||||||
operationId: 'deviceRegister',
|
|
||||||
summary: '注册设备',
|
|
||||||
description: '注册 licence 与平台公钥,指纹由 UX 本机计算,返回设备信息。',
|
|
||||||
tags: ['Device'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
licence: z.string().min(1).describe('设备授权码 licence'),
|
|
||||||
platformPublicKey: z.string().min(1).describe('平台公钥(Base64,SPKI DER)'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(deviceOutput)
|
|
||||||
|
|
||||||
export const get = oc
|
|
||||||
.route({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/device/get',
|
|
||||||
operationId: 'deviceGet',
|
|
||||||
summary: '查询设备',
|
|
||||||
description: '按 id 或 licence 查询设备信息。',
|
|
||||||
tags: ['Device'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: z.string().optional().describe('设备 ID,与 licence 二选一'),
|
|
||||||
licence: z.string().optional().describe('设备授权码,与 id 二选一'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(deviceOutput)
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
|
import * as config from './config.contract'
|
||||||
import * as crypto from './crypto.contract'
|
import * as crypto from './crypto.contract'
|
||||||
import * as device from './device.contract'
|
|
||||||
import * as task from './task.contract'
|
|
||||||
|
|
||||||
export const contract = {
|
export const contract = {
|
||||||
device,
|
config,
|
||||||
crypto,
|
crypto,
|
||||||
task,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Contract = typeof contract
|
export type Contract = typeof contract
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { oc } from '@orpc/contract'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const taskOutput = z.object({
|
|
||||||
id: z.string().describe('任务记录 ID'),
|
|
||||||
deviceId: z.string().describe('设备 ID'),
|
|
||||||
taskId: z.string().describe('任务业务 ID'),
|
|
||||||
enterpriseId: z.string().nullable().describe('企业 ID'),
|
|
||||||
orgName: z.string().nullable().describe('单位名称'),
|
|
||||||
inspectionId: z.string().nullable().describe('检查 ID'),
|
|
||||||
inspectionPerson: z.string().nullable().describe('检查人'),
|
|
||||||
issuedAt: z.date().nullable().describe('任务发布时间(ISO date-time;由毫秒时间戳转换后存储)'),
|
|
||||||
status: z.enum(['pending', 'in_progress', 'done']).describe('任务状态'),
|
|
||||||
createdAt: z.date().describe('记录创建时间'),
|
|
||||||
updatedAt: z.date().describe('记录更新时间'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const save = oc
|
|
||||||
.route({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/task/save',
|
|
||||||
operationId: 'taskSave',
|
|
||||||
summary: '保存任务',
|
|
||||||
description: '保存解密后的任务信息到 UX 数据库。',
|
|
||||||
tags: ['Task'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
deviceId: z.string().min(1).describe('设备 ID'),
|
|
||||||
taskId: z.string().min(1).describe('任务 ID'),
|
|
||||||
enterpriseId: z.string().optional().describe('企业 ID'),
|
|
||||||
orgName: z.string().optional().describe('单位名称'),
|
|
||||||
inspectionId: z.string().optional().describe('检查 ID'),
|
|
||||||
inspectionPerson: z.string().optional().describe('检查人'),
|
|
||||||
issuedAt: z.number().optional().describe('任务发布时间戳(毫秒)'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(taskOutput)
|
|
||||||
|
|
||||||
export const list = oc
|
|
||||||
.route({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/task/list',
|
|
||||||
operationId: 'taskList',
|
|
||||||
summary: '查询任务列表',
|
|
||||||
description: '按设备 ID 查询任务列表。',
|
|
||||||
tags: ['Task'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
deviceId: z.string().min(1).describe('设备 ID'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(z.array(taskOutput))
|
|
||||||
|
|
||||||
export const updateStatus = oc
|
|
||||||
.route({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/task/update-status',
|
|
||||||
operationId: 'taskUpdateStatus',
|
|
||||||
summary: '更新任务状态',
|
|
||||||
description: '按记录 ID 更新任务状态。',
|
|
||||||
tags: ['Task'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: z.string().min(1).describe('任务记录 ID'),
|
|
||||||
status: z.enum(['pending', 'in_progress', 'done']).describe('目标状态'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(taskOutput)
|
|
||||||
19
apps/server/src/server/api/routers/config.router.ts
Normal file
19
apps/server/src/server/api/routers/config.router.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ensureUxConfig, setUxLicence } 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 {
|
||||||
|
licence: config.licence,
|
||||||
|
fingerprint: config.fingerprint,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -11,40 +11,19 @@ import {
|
|||||||
import { ORPCError } from '@orpc/server'
|
import { ORPCError } from '@orpc/server'
|
||||||
import type { JSZipObject } from 'jszip'
|
import type { JSZipObject } from 'jszip'
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import { z } from 'zod'
|
import { getUxConfig } from '@/server/ux-config'
|
||||||
import { db } from '../middlewares'
|
import { db } from '../middlewares'
|
||||||
import { os } from '../server'
|
import { os } from '../server'
|
||||||
|
|
||||||
interface DeviceRow {
|
interface ZipFileItem {
|
||||||
id: string
|
name: string
|
||||||
licence: string
|
bytes: Uint8Array
|
||||||
fingerprint: string
|
|
||||||
platformPublicKey: string
|
|
||||||
pgpPrivateKey: string | null
|
|
||||||
pgpPublicKey: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReportFiles {
|
|
||||||
assets: Uint8Array
|
|
||||||
vulnerabilities: Uint8Array
|
|
||||||
weakPasswords: Uint8Array
|
|
||||||
reportHtml: Uint8Array
|
|
||||||
reportHtmlName: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RAW_ZIP_BYTES = 50 * 1024 * 1024
|
const MAX_RAW_ZIP_BYTES = 50 * 1024 * 1024
|
||||||
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024
|
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024
|
||||||
const MAX_TOTAL_UNCOMPRESSED_BYTES = 60 * 1024 * 1024
|
const MAX_TOTAL_UNCOMPRESSED_BYTES = 60 * 1024 * 1024
|
||||||
const MAX_ZIP_ENTRIES = 32
|
const MAX_ZIP_ENTRIES = 64
|
||||||
|
|
||||||
const taskPayloadSchema = z.object({
|
|
||||||
taskId: z.string().min(1),
|
|
||||||
enterpriseId: z.string().min(1),
|
|
||||||
orgName: z.string().min(1),
|
|
||||||
inspectionId: z.string().min(1),
|
|
||||||
inspectionPerson: z.string().min(1),
|
|
||||||
issuedAt: z.number(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const normalizePath = (name: string): string => name.replaceAll('\\', '/')
|
const normalizePath = (name: string): string => name.replaceAll('\\', '/')
|
||||||
|
|
||||||
@@ -59,21 +38,8 @@ const isUnsafePath = (name: string): boolean => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBaseName = (name: string): string => {
|
const listSafeZipFiles = async (rawZip: JSZip): Promise<ZipFileItem[]> => {
|
||||||
const normalized = normalizePath(name)
|
|
||||||
const parts = normalized.split('/')
|
|
||||||
return parts.at(-1) ?? normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRequiredReportFiles = async (rawZip: JSZip): Promise<ReportFiles> => {
|
|
||||||
let assets: Uint8Array | null = null
|
|
||||||
let vulnerabilities: Uint8Array | null = null
|
|
||||||
let weakPasswords: Uint8Array | null = null
|
|
||||||
let reportHtml: Uint8Array | null = null
|
|
||||||
let reportHtmlName: string | null = null
|
|
||||||
|
|
||||||
const entries = Object.values(rawZip.files) as JSZipObject[]
|
const entries = Object.values(rawZip.files) as JSZipObject[]
|
||||||
|
|
||||||
if (entries.length > MAX_ZIP_ENTRIES) {
|
if (entries.length > MAX_ZIP_ENTRIES) {
|
||||||
throw new ORPCError('BAD_REQUEST', {
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
message: `Zip contains too many entries: ${entries.length}`,
|
message: `Zip contains too many entries: ${entries.length}`,
|
||||||
@@ -81,6 +47,8 @@ const getRequiredReportFiles = async (rawZip: JSZip): Promise<ReportFiles> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let totalUncompressedBytes = 0
|
let totalUncompressedBytes = 0
|
||||||
|
const files: ZipFileItem[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.dir) {
|
if (entry.dir) {
|
||||||
@@ -93,10 +61,18 @@ const getRequiredReportFiles = async (rawZip: JSZip): Promise<ReportFiles> => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedName = normalizePath(entry.name)
|
||||||
|
if (seen.has(normalizedName)) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: `Zip contains duplicate entry: ${normalizedName}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
seen.add(normalizedName)
|
||||||
|
|
||||||
const content = await entry.async('uint8array')
|
const content = await entry.async('uint8array')
|
||||||
if (content.byteLength > MAX_SINGLE_FILE_BYTES) {
|
if (content.byteLength > MAX_SINGLE_FILE_BYTES) {
|
||||||
throw new ORPCError('BAD_REQUEST', {
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
message: `Zip entry too large: ${entry.name}`,
|
message: `Zip entry too large: ${normalizedName}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,121 +83,84 @@ const getRequiredReportFiles = async (rawZip: JSZip): Promise<ReportFiles> => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = getBaseName(entry.name)
|
files.push({
|
||||||
const lowerFileName = fileName.toLowerCase()
|
name: normalizedName,
|
||||||
|
bytes: content,
|
||||||
if (lowerFileName === 'assets.json') {
|
|
||||||
if (assets) {
|
|
||||||
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate assets.json' })
|
|
||||||
}
|
|
||||||
assets = content
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (lowerFileName === 'vulnerabilities.json') {
|
|
||||||
if (vulnerabilities) {
|
|
||||||
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate vulnerabilities.json' })
|
|
||||||
}
|
|
||||||
vulnerabilities = content
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (lowerFileName === 'weakpasswords.json') {
|
|
||||||
if (weakPasswords) {
|
|
||||||
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate weakPasswords.json' })
|
|
||||||
}
|
|
||||||
weakPasswords = content
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (fileName.includes('漏洞评估报告') && lowerFileName.endsWith('.html')) {
|
|
||||||
if (reportHtml) {
|
|
||||||
throw new ORPCError('BAD_REQUEST', {
|
|
||||||
message: 'Zip contains multiple 漏洞评估报告*.html files',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
reportHtml = content
|
|
||||||
reportHtmlName = fileName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assets || !vulnerabilities || !weakPasswords || !reportHtml || !reportHtmlName) {
|
|
||||||
throw new ORPCError('BAD_REQUEST', {
|
|
||||||
message:
|
|
||||||
'Zip missing required files. Required: assets.json, vulnerabilities.json, weakPasswords.json, and 漏洞评估报告*.html',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (files.length === 0) {
|
||||||
assets,
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
vulnerabilities,
|
message: 'Zip has no file entries',
|
||||||
weakPasswords,
|
})
|
||||||
reportHtml,
|
|
||||||
reportHtmlName,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDevice = async (
|
const parseSummaryJsonOrThrow = (summaryJson: string): string => {
|
||||||
context: {
|
try {
|
||||||
db: { query: { deviceTable: { findFirst: (args: { where: { id: string } }) => Promise<DeviceRow | undefined> } } }
|
const parsed = JSON.parse(summaryJson)
|
||||||
},
|
return JSON.stringify(parsed)
|
||||||
deviceId: string,
|
} catch (error) {
|
||||||
): Promise<DeviceRow> => {
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
const device = await context.db.query.deviceTable.findFirst({
|
message: `summaryJson is not valid JSON: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
where: { id: deviceId },
|
|
||||||
})
|
})
|
||||||
if (!device) {
|
|
||||||
throw new ORPCError('NOT_FOUND', { message: 'Device not found' })
|
|
||||||
}
|
}
|
||||||
return device
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => {
|
export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => {
|
||||||
const device = await getDevice(context, input.deviceId)
|
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 deviceInfoJson = JSON.stringify({
|
const deviceInfoJson = JSON.stringify({
|
||||||
licence: device.licence,
|
licence: config.licence,
|
||||||
fingerprint: device.fingerprint,
|
fingerprint: config.fingerprint,
|
||||||
})
|
})
|
||||||
|
|
||||||
const encrypted = rsaOaepEncrypt(deviceInfoJson, device.platformPublicKey)
|
const encrypted = rsaOaepEncrypt(deviceInfoJson, input.platformPublicKey)
|
||||||
|
|
||||||
return { encrypted }
|
return { encrypted }
|
||||||
})
|
})
|
||||||
|
|
||||||
export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ context, input }) => {
|
export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ context, input }) => {
|
||||||
const device = await getDevice(context, input.deviceId)
|
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 key = sha256(device.licence + device.fingerprint)
|
const key = sha256(config.licence + config.fingerprint)
|
||||||
const decryptedJson = aesGcmDecrypt(input.encryptedData, key)
|
const decrypted = aesGcmDecrypt(input.encryptedData, key)
|
||||||
const taskData = taskPayloadSchema.parse(JSON.parse(decryptedJson))
|
return { decrypted }
|
||||||
|
|
||||||
return taskData
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({ context, input }) => {
|
export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({ context, input }) => {
|
||||||
const device = await getDevice(context, input.deviceId)
|
const config = await getUxConfig(context.db)
|
||||||
|
if (!config || !config.licence) {
|
||||||
const ikm = device.licence + device.fingerprint
|
throw new ORPCError('PRECONDITION_FAILED', {
|
||||||
const aesKey = hkdfSha256(ikm, input.taskId, 'inspection_report_encryption')
|
message: 'Local identity is not initialized. Call config.get and then config.setLicence first.',
|
||||||
|
|
||||||
const timestamp = Date.now()
|
|
||||||
const plaintextJson = JSON.stringify({
|
|
||||||
enterpriseId: input.enterpriseId,
|
|
||||||
inspectionId: input.inspectionId,
|
|
||||||
summary: input.summary,
|
|
||||||
timestamp,
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const encrypted = aesGcmEncrypt(plaintextJson, aesKey)
|
const ikm = config.licence + config.fingerprint
|
||||||
|
const aesKey = hkdfSha256(ikm, input.salt, 'inspection_report_encryption')
|
||||||
const qrContent = JSON.stringify({
|
const encrypted = aesGcmEncrypt(input.plaintext, aesKey)
|
||||||
taskId: input.taskId,
|
return { encrypted }
|
||||||
encrypted,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { qrContent }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(async ({ context, input }) => {
|
export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(async ({ context, input }) => {
|
||||||
const device = await getDevice(context, input.deviceId)
|
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 rawZipArrayBuffer = await input.rawZip.arrayBuffer()
|
const rawZipArrayBuffer = await input.rawZip.arrayBuffer()
|
||||||
const rawZipBytes = Buffer.from(rawZipArrayBuffer)
|
const rawZipBytes = Buffer.from(rawZipArrayBuffer)
|
||||||
|
|
||||||
@@ -239,59 +178,49 @@ export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(asy
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const reportFiles = await getRequiredReportFiles(rawZip)
|
const zipFiles = await listSafeZipFiles(rawZip)
|
||||||
|
|
||||||
const ikm = device.licence + device.fingerprint
|
const ikm = config.licence + config.fingerprint
|
||||||
const signingKey = hkdfSha256(ikm, 'AUTH_V3_SALT', 'device_report_signature')
|
const signingKey = hkdfSha256(ikm, 'AUTH_V3_SALT', 'device_report_signature')
|
||||||
|
|
||||||
const assetsHash = sha256Hex(Buffer.from(reportFiles.assets))
|
const fileHashEntries = zipFiles
|
||||||
const vulnerabilitiesHash = sha256Hex(Buffer.from(reportFiles.vulnerabilities))
|
.map((item) => ({
|
||||||
const weakPasswordsHash = sha256Hex(Buffer.from(reportFiles.weakPasswords))
|
name: item.name,
|
||||||
const reportHtmlHash = sha256Hex(Buffer.from(reportFiles.reportHtml))
|
hash: sha256Hex(Buffer.from(item.bytes)),
|
||||||
|
}))
|
||||||
const signPayload =
|
.sort((a, b) => a.name.localeCompare(b.name, 'en'))
|
||||||
input.taskId + input.inspectionId + assetsHash + vulnerabilitiesHash + weakPasswordsHash + reportHtmlHash
|
|
||||||
|
|
||||||
|
const hashPayload = fileHashEntries.map((item) => `${item.name}:${item.hash}`).join('|')
|
||||||
|
const signPayload = `${input.signingContext}|${hashPayload}`
|
||||||
const deviceSignature = hmacSha256Base64(signingKey, signPayload)
|
const deviceSignature = hmacSha256Base64(signingKey, signPayload)
|
||||||
|
|
||||||
if (!device.pgpPrivateKey) {
|
const normalizedSummaryJson = parseSummaryJsonOrThrow(input.summaryJson)
|
||||||
throw new ORPCError('PRECONDITION_FAILED', {
|
|
||||||
message: 'Device does not have a PGP key pair. Re-register the device.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const summaryObject = {
|
const summaryObject = {
|
||||||
enterpriseId: input.enterpriseId,
|
|
||||||
inspectionId: input.inspectionId,
|
|
||||||
taskId: input.taskId,
|
|
||||||
licence: device.licence,
|
|
||||||
fingerprint: device.fingerprint,
|
|
||||||
deviceSignature,
|
deviceSignature,
|
||||||
summary: input.summary,
|
signingContext: input.signingContext,
|
||||||
|
payload: JSON.parse(normalizedSummaryJson),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryBytes = Buffer.from(JSON.stringify(summaryObject), 'utf-8')
|
const summaryBytes = Buffer.from(JSON.stringify(summaryObject), 'utf-8')
|
||||||
|
|
||||||
const manifestObject = {
|
const manifestFiles: Record<string, string> = {
|
||||||
files: {
|
|
||||||
'summary.json': sha256Hex(summaryBytes),
|
'summary.json': sha256Hex(summaryBytes),
|
||||||
'assets.json': assetsHash,
|
}
|
||||||
'vulnerabilities.json': vulnerabilitiesHash,
|
for (const item of fileHashEntries) {
|
||||||
'weakPasswords.json': weakPasswordsHash,
|
manifestFiles[item.name] = item.hash
|
||||||
[reportFiles.reportHtmlName]: reportHtmlHash,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const manifestObject = {
|
||||||
|
files: manifestFiles,
|
||||||
|
}
|
||||||
const manifestBytes = Buffer.from(JSON.stringify(manifestObject, null, 2), 'utf-8')
|
const manifestBytes = Buffer.from(JSON.stringify(manifestObject, null, 2), 'utf-8')
|
||||||
const signatureAsc = await pgpSignDetached(manifestBytes, device.pgpPrivateKey)
|
const signatureAsc = await pgpSignDetached(manifestBytes, input.pgpPrivateKey)
|
||||||
|
|
||||||
const signedZip = new JSZip()
|
const signedZip = new JSZip()
|
||||||
signedZip.file('summary.json', summaryBytes)
|
signedZip.file('summary.json', summaryBytes)
|
||||||
signedZip.file('assets.json', reportFiles.assets)
|
for (const item of zipFiles) {
|
||||||
signedZip.file('vulnerabilities.json', reportFiles.vulnerabilities)
|
signedZip.file(item.name, item.bytes)
|
||||||
signedZip.file('weakPasswords.json', reportFiles.weakPasswords)
|
}
|
||||||
signedZip.file(reportFiles.reportHtmlName, reportFiles.reportHtml)
|
|
||||||
signedZip.file('META-INF/manifest.json', manifestBytes)
|
signedZip.file('META-INF/manifest.json', manifestBytes)
|
||||||
signedZip.file('META-INF/signature.asc', signatureAsc)
|
signedZip.file('META-INF/signature.asc', signatureAsc)
|
||||||
|
|
||||||
@@ -301,7 +230,7 @@ export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(asy
|
|||||||
compressionOptions: { level: 9 },
|
compressionOptions: { level: 9 },
|
||||||
})
|
})
|
||||||
|
|
||||||
return new File([Buffer.from(signedZipBytes)], `${input.taskId}-signed-report.zip`, {
|
return new File([Buffer.from(signedZipBytes)], input.outputFileName ?? 'signed-report.zip', {
|
||||||
type: 'application/zip',
|
type: 'application/zip',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { generatePgpKeyPair } from '@furtherverse/crypto'
|
|
||||||
import { ORPCError } from '@orpc/server'
|
|
||||||
import { deviceTable } from '@/server/db/schema'
|
|
||||||
import { computeDeviceFingerprint } from '@/server/device-fingerprint'
|
|
||||||
import { db } from '../middlewares'
|
|
||||||
import { os } from '../server'
|
|
||||||
|
|
||||||
export const register = os.device.register.use(db).handler(async ({ context, input }) => {
|
|
||||||
const existing = await context.db.query.deviceTable.findFirst({
|
|
||||||
where: { licence: input.licence },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
throw new ORPCError('CONFLICT', {
|
|
||||||
message: `Device with licence "${input.licence}" already registered`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const pgpKeys = await generatePgpKeyPair(input.licence, `${input.licence}@ux.local`)
|
|
||||||
const fingerprint = computeDeviceFingerprint()
|
|
||||||
|
|
||||||
const rows = await context.db
|
|
||||||
.insert(deviceTable)
|
|
||||||
.values({
|
|
||||||
licence: input.licence,
|
|
||||||
fingerprint,
|
|
||||||
platformPublicKey: input.platformPublicKey,
|
|
||||||
pgpPrivateKey: pgpKeys.privateKey,
|
|
||||||
pgpPublicKey: pgpKeys.publicKey,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return rows[0] as (typeof rows)[number]
|
|
||||||
})
|
|
||||||
|
|
||||||
export const get = os.device.get.use(db).handler(async ({ context, input }) => {
|
|
||||||
if (!input.id && !input.licence) {
|
|
||||||
throw new ORPCError('BAD_REQUEST', {
|
|
||||||
message: 'Either id or licence must be provided',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const device = input.id
|
|
||||||
? await context.db.query.deviceTable.findFirst({ where: { id: input.id } })
|
|
||||||
: await context.db.query.deviceTable.findFirst({ where: { licence: input.licence } })
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
throw new ORPCError('NOT_FOUND', {
|
|
||||||
message: 'Device not found',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return device
|
|
||||||
})
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { os } from '../server'
|
import { os } from '../server'
|
||||||
|
import * as config from './config.router'
|
||||||
import * as crypto from './crypto.router'
|
import * as crypto from './crypto.router'
|
||||||
import * as device from './device.router'
|
|
||||||
import * as task from './task.router'
|
|
||||||
|
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
device,
|
config,
|
||||||
crypto,
|
crypto,
|
||||||
task,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { ORPCError } from '@orpc/server'
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { taskTable } from '@/server/db/schema'
|
|
||||||
import { db } from '../middlewares'
|
|
||||||
import { os } from '../server'
|
|
||||||
|
|
||||||
export const save = os.task.save.use(db).handler(async ({ context, input }) => {
|
|
||||||
const rows = await context.db
|
|
||||||
.insert(taskTable)
|
|
||||||
.values({
|
|
||||||
deviceId: input.deviceId,
|
|
||||||
taskId: input.taskId,
|
|
||||||
enterpriseId: input.enterpriseId,
|
|
||||||
orgName: input.orgName,
|
|
||||||
inspectionId: input.inspectionId,
|
|
||||||
inspectionPerson: input.inspectionPerson,
|
|
||||||
issuedAt: input.issuedAt ? new Date(input.issuedAt) : null,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return rows[0] as (typeof rows)[number]
|
|
||||||
})
|
|
||||||
|
|
||||||
export const list = os.task.list.use(db).handler(async ({ context, input }) => {
|
|
||||||
return await context.db.query.taskTable.findMany({
|
|
||||||
where: { deviceId: input.deviceId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const updateStatus = os.task.updateStatus.use(db).handler(async ({ context, input }) => {
|
|
||||||
const rows = await context.db
|
|
||||||
.update(taskTable)
|
|
||||||
.set({ status: input.status })
|
|
||||||
.where(eq(taskTable.id, input.id))
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
const updated = rows[0]
|
|
||||||
if (!updated) {
|
|
||||||
throw new ORPCError('NOT_FOUND', { message: 'Task not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './device'
|
export * from './device'
|
||||||
export * from './task'
|
export * from './task'
|
||||||
|
export * from './ux-config'
|
||||||
|
|||||||
9
apps/server/src/server/db/schema/ux-config.ts
Normal file
9
apps/server/src/server/db/schema/ux-config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
import { generatedFields } from '../fields'
|
||||||
|
|
||||||
|
export const uxConfigTable = sqliteTable('ux_config', {
|
||||||
|
...generatedFields,
|
||||||
|
singletonKey: text('singleton_key').notNull().unique().default('default'),
|
||||||
|
licence: text('licence'),
|
||||||
|
fingerprint: text('fingerprint').notNull(),
|
||||||
|
})
|
||||||
40
apps/server/src/server/ux-config.ts
Normal file
40
apps/server/src/server/ux-config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import type { DB } from '@/server/db'
|
||||||
|
import { uxConfigTable } from '@/server/db/schema'
|
||||||
|
import { computeDeviceFingerprint } from './device-fingerprint'
|
||||||
|
|
||||||
|
const UX_CONFIG_KEY = 'default'
|
||||||
|
|
||||||
|
export const getUxConfig = async (db: DB) => {
|
||||||
|
return await db.query.uxConfigTable.findFirst({
|
||||||
|
where: { singletonKey: UX_CONFIG_KEY },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureUxConfig = async (db: DB) => {
|
||||||
|
const existing = await getUxConfig(db)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = computeDeviceFingerprint()
|
||||||
|
const rows = await db
|
||||||
|
.insert(uxConfigTable)
|
||||||
|
.values({
|
||||||
|
singletonKey: UX_CONFIG_KEY,
|
||||||
|
fingerprint,
|
||||||
|
licence: null,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return rows[0] as (typeof rows)[number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setUxLicence = async (db: DB, licence: string) => {
|
||||||
|
const config = await ensureUxConfig(db)
|
||||||
|
|
||||||
|
const rows = await db.update(uxConfigTable).set({ licence }).where(eq(uxConfigTable.id, config.id)).returning()
|
||||||
|
|
||||||
|
return rows[0] as (typeof rows)[number]
|
||||||
|
}
|
||||||
@@ -1,71 +1,75 @@
|
|||||||
# UX 授权端接口说明
|
# UX 授权端接口说明
|
||||||
|
|
||||||
本文档描述当前 UX 服务端实现的授权对接接口与职责边界。
|
本文档描述 UX 服务当前定位:**仅提供底层密码学能力,不维护业务状态**。
|
||||||
|
|
||||||
## 1. 职责边界
|
## 1. 职责边界
|
||||||
|
|
||||||
- UX **只与工具箱交互**(HTTP RPC),不直接与手机 App 交互。
|
- UX 只提供加密、解密、签名、ZIP 打包能力
|
||||||
- 手机 App 仅承担扫码和与管理平台联网通信。
|
- UX 不持有设备/任务等业务主数据,不读取业务数据库
|
||||||
- 报告签名流程由工具箱上传原始 ZIP 到 UX,UX 返回已签名 ZIP。
|
- 调用方(工具箱)负责提供业务上下文与密钥材料
|
||||||
|
|
||||||
## 2. 设备注册
|
## 2. 核心接口
|
||||||
|
|
||||||
`device.register`
|
### 2.0 `config.get` / `config.setLicence`
|
||||||
|
|
||||||
- 输入:`licence`、`platformPublicKey`
|
- `config.get`:读取本地配置(`licence`、`fingerprint`)
|
||||||
- UX 在本机采集设备特征并计算 `fingerprint`
|
- `config.setLicence`:写入本地 `licence`
|
||||||
- UX 将 `licence + fingerprint + 公钥 + PGP 密钥对` 持久化到数据库
|
- 说明:`fingerprint` 由 UX 本机计算并持久化,不由调用方传入
|
||||||
|
|
||||||
## 3. 核心加密接口
|
### 2.1 `crypto.encryptDeviceInfo`
|
||||||
|
|
||||||
### 3.1 设备授权二维码密文
|
- 输入:`platformPublicKey`
|
||||||
|
- 输出:`encrypted`(Base64)
|
||||||
|
- 说明:读取本地 `licence`/`fingerprint` 组装 JSON 后执行 RSA-OAEP 加密
|
||||||
|
|
||||||
`crypto.encryptDeviceInfo`
|
### 2.2 `crypto.decryptTask`
|
||||||
|
|
||||||
- 使用平台公钥 RSA-OAEP 加密:`{ licence, fingerprint }`
|
- 输入:`encryptedData`
|
||||||
- 返回 Base64 密文(工具箱用于生成二维码)
|
- 输出:`decrypted`(明文字符串)
|
||||||
|
- 说明:使用本地 `licence`/`fingerprint` 推导 `SHA256(licence + fingerprint)` 作为 AES-GCM 密钥
|
||||||
|
|
||||||
### 3.2 任务二维码解密
|
### 2.3 `crypto.encryptSummary`
|
||||||
|
|
||||||
`crypto.decryptTask`
|
- 输入:`salt`、`plaintext`
|
||||||
|
- 输出:`encrypted`(Base64)
|
||||||
|
- 说明:使用本地 `licence`/`fingerprint` 做 HKDF-SHA256 + AES-256-GCM 加密
|
||||||
|
|
||||||
- 密钥:`SHA256(licence + fingerprint)`
|
### 2.4 `crypto.signAndPackReport`
|
||||||
- 算法:AES-256-GCM
|
|
||||||
- 输入:任务二维码中的 Base64 密文
|
|
||||||
- 输出:任务 JSON
|
|
||||||
|
|
||||||
### 3.3 摘要二维码加密
|
- 输入:
|
||||||
|
- `rawZip`(multipart 文件)
|
||||||
|
- `pgpPrivateKey`
|
||||||
|
- `signingContext`
|
||||||
|
- `summaryJson`
|
||||||
|
- `outputFileName`(可选)
|
||||||
|
- 输出:签名后 ZIP 文件(二进制,`application/zip`)
|
||||||
|
|
||||||
`crypto.encryptSummary`
|
`summary.json` 由 UX 生成,结构为:
|
||||||
|
|
||||||
- 密钥派生:HKDF-SHA256
|
```json
|
||||||
- `ikm = licence + fingerprint`
|
{
|
||||||
- `salt = taskId`
|
"deviceSignature": "Base64...",
|
||||||
- `info = "inspection_report_encryption"`
|
"signingContext": "...",
|
||||||
- 算法:AES-256-GCM
|
"payload": { "...": "调用方 summaryJson" },
|
||||||
- 输出:`{ taskId, encrypted }` JSON(工具箱用于生成二维码)
|
"timestamp": 1734571234567
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 3.4 原始 ZIP 签名打包(最终报告)
|
签名输出 ZIP 固定包含:
|
||||||
|
|
||||||
`crypto.signAndPackReport`
|
- `summary.json`
|
||||||
|
- `META-INF/manifest.json`
|
||||||
|
- `META-INF/signature.asc`
|
||||||
|
|
||||||
- 输入:`rawZip`(`multipart/form-data` 文件字段) + `taskId` + `inspectionId` + `enterpriseId` + `summary`
|
## 3. ZIP 安全约束
|
||||||
- UX 在服务端完成:
|
|
||||||
1. 校验并解包原始 ZIP
|
|
||||||
2. 计算文件 SHA-256
|
|
||||||
3. HKDF + HMAC 生成 `deviceSignature`
|
|
||||||
4. 生成 `summary.json`
|
|
||||||
5. 生成 `META-INF/manifest.json`
|
|
||||||
6. OpenPGP 分离签名生成 `META-INF/signature.asc`
|
|
||||||
7. 重新打包为 signed ZIP
|
|
||||||
- 输出:签名后 ZIP 文件(二进制响应,`application/zip`)
|
|
||||||
|
|
||||||
## 4. 安全约束(签名打包)
|
- 拒绝危险路径(防 Zip Slip)
|
||||||
|
- 原始 ZIP ≤ `50 MiB`
|
||||||
|
- 单个文件 ≤ `20 MiB`
|
||||||
|
- 总解压后大小 ≤ `60 MiB`
|
||||||
|
- ZIP 条目数量 ≤ `64`
|
||||||
|
|
||||||
- 拒绝危险 ZIP 路径(防 Zip Slip)
|
## 4. OpenAPI
|
||||||
- 限制原始 ZIP 和单文件大小
|
|
||||||
- 强制存在以下文件:
|
- 文档:`/api/docs`
|
||||||
- `assets.json`
|
- 规范:`/api/spec.json`
|
||||||
- `vulnerabilities.json`
|
|
||||||
- `weakPasswords.json`
|
|
||||||
- `漏洞评估报告*.html`
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
> ### UX 集成模式补充(当前项目实现)
|
> ### UX 集成模式补充(当前项目实现)
|
||||||
>
|
>
|
||||||
> 在当前集成模式中,工具箱扫描二维码后将密文提交给 UX 的 `crypto.decryptTask`,
|
> 在当前集成模式中,工具箱扫描二维码后将密文提交给 UX 的 `crypto.decryptTask`。
|
||||||
> 由 UX 使用设备绑定的 `licence + fingerprint` 执行 AES-256-GCM 解密并返回任务明文。
|
> UX 从本地配置读取 licence/fingerprint 执行底层解密并返回明文字符串。
|
||||||
|
|
||||||
## 一、业务流程
|
## 一、业务流程
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
>
|
>
|
||||||
> 在当前集成模式中,工具箱可将原始报告 ZIP 直接上传到 UX 的 `crypto.signAndPackReport`:
|
> 在当前集成模式中,工具箱可将原始报告 ZIP 直接上传到 UX 的 `crypto.signAndPackReport`:
|
||||||
>
|
>
|
||||||
> 1. UX 校验 ZIP 并提取必需文件;
|
> 1. 工具箱先通过 `config.setLicence` 完成本地 licence 配置;
|
||||||
> 2. UX 生成 `deviceSignature`、`summary.json`、`META-INF/manifest.json`、`META-INF/signature.asc`;
|
> 2. 工具箱传入 `pgpPrivateKey`、`signingContext`、`summaryJson` 与 `rawZip`;
|
||||||
> 3. UX 重新打包并返回签名后的 ZIP(二进制文件响应),工具箱再用于离线介质回传平台。
|
> 3. UX 从本地配置读取 licence/fingerprint,执行签名与打包能力,生成 `summary.json`、`META-INF/manifest.json`、`META-INF/signature.asc`;
|
||||||
|
> 4. UX 返回签名后的 ZIP(二进制文件响应),工具箱再用于离线介质回传平台。
|
||||||
|
|
||||||
## 一、ZIP 文件结构要求
|
## 一、ZIP 文件结构要求
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
> ### UX 集成模式补充(当前项目实现)
|
> ### UX 集成模式补充(当前项目实现)
|
||||||
>
|
>
|
||||||
> 在当前集成模式中,工具箱将摘要明文传给 UX 的 `crypto.encryptSummary`,
|
> 在当前集成模式中,工具箱将明文文本传给 UX 的 `crypto.encryptSummary`,并提供 `salt`。
|
||||||
> 由 UX 执行 HKDF + AES-256-GCM 加密并返回二维码内容 JSON(`taskId + encrypted`)。
|
> UX 从本地配置读取 licence/fingerprint,执行 HKDF + AES-256-GCM 并返回 Base64 密文。
|
||||||
|
|
||||||
## 一、业务流程
|
## 一、业务流程
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
> ### UX 集成模式补充(当前项目实现)
|
> ### UX 集成模式补充(当前项目实现)
|
||||||
>
|
>
|
||||||
> 在当前集成模式中,工具箱不直接执行 RSA 加密,而是调用 UX 接口:
|
> 调用前提:工具箱先调用 `config.setLicence` 写入本地 licence(fingerprint 由 UX 本机计算并持久化)。
|
||||||
>
|
>
|
||||||
> 1. 工具箱先调用 `device.register` 传入 `licence` 与平台公钥,`fingerprint` 由 UX 本机计算并入库。
|
> 在当前集成模式中,工具箱调用 UX 的 `crypto.encryptDeviceInfo`,直接传入
|
||||||
> 2. 工具箱再调用 `crypto.encryptDeviceInfo` 获取加密后的 Base64 密文。
|
> `platformPublicKey` 获取加密后的 Base64 密文。
|
||||||
> 3. 工具箱将该密文生成二维码供 App 扫码提交平台。
|
> UX 不保存业务设备实体,仅保存本机身份材料(licence/fingerprint)。
|
||||||
|
|
||||||
## 一、业务流程
|
## 一、业务流程
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +1,84 @@
|
|||||||
# 第三方 OpenAPI 对接指南
|
# 第三方 OpenAPI 对接指南
|
||||||
|
|
||||||
本文档用于第三方系统快速接入 UX 授权服务。
|
本文档用于第三方系统对接 UX 底层能力服务。
|
||||||
|
|
||||||
## 1. 文档入口
|
## 1. 文档入口
|
||||||
|
|
||||||
- OpenAPI 文档(Scalar):`/api/docs`
|
- OpenAPI 文档(Scalar):`/api/docs`
|
||||||
- OpenAPI 规范(JSON):`/api/spec.json`
|
- OpenAPI 规范(JSON):`/api/spec.json`
|
||||||
|
|
||||||
例如本地开发环境:
|
本地开发环境示例:
|
||||||
|
|
||||||
- `http://localhost:3000/api/docs`
|
- `http://localhost:3000/api/docs`
|
||||||
- `http://localhost:3000/api/spec.json`
|
- `http://localhost:3000/api/spec.json`
|
||||||
|
|
||||||
## 2. 接口分组
|
## 2. 服务边界
|
||||||
|
|
||||||
OpenAPI 中已按 `tags` 分组:
|
UX 仅提供底层加密/解密/签名能力,不维护业务实体(设备、任务、组织等)。
|
||||||
|
|
||||||
- `Device`:设备注册与查询
|
- 调用方负责提供业务上下文与必要签名材料(如 `platformPublicKey`、`pgpPrivateKey`)
|
||||||
- `Crypto`:授权加解密与二维码数据处理
|
- 调用方负责业务字段定义与存储
|
||||||
- `Report`:报告签名打包
|
|
||||||
- `Task`:任务保存与状态管理
|
|
||||||
|
|
||||||
## 3. 核心接口一览
|
## 3. 核心接口
|
||||||
|
|
||||||
### Device
|
- `POST /api/config/get`
|
||||||
|
- operationId: `configGet`
|
||||||
|
- 作用:读取 UX 本地 `licence` 与 `fingerprint`
|
||||||
|
|
||||||
- `POST /api/device/register`
|
- `POST /api/config/set-licence`
|
||||||
- 操作名:`deviceRegister`
|
- operationId: `configSetLicence`
|
||||||
- 说明:注册设备,UX 计算并存储 fingerprint
|
- 作用:设置 UX 本地 `licence`(`fingerprint` 由 UX 本机计算并持久化)
|
||||||
- `POST /api/device/get`
|
|
||||||
- 操作名:`deviceGet`
|
|
||||||
- 说明:按 `id` 或 `licence` 查询设备
|
|
||||||
|
|
||||||
### Crypto
|
|
||||||
|
|
||||||
- `POST /api/crypto/encrypt-device-info`
|
- `POST /api/crypto/encrypt-device-info`
|
||||||
- 操作名:`encryptDeviceInfo`
|
- operationId: `encryptDeviceInfo`
|
||||||
- 说明:生成设备授权二维码密文
|
- 作用:使用本地身份(licence/fingerprint)与输入 `platformPublicKey` 生成授权密文
|
||||||
|
|
||||||
- `POST /api/crypto/decrypt-task`
|
- `POST /api/crypto/decrypt-task`
|
||||||
- 操作名:`decryptTask`
|
- operationId: `decryptTask`
|
||||||
- 说明:解密任务二维码数据
|
- 作用:基于 `SHA256(licence+fingerprint)` 解密 AES-GCM 密文
|
||||||
|
|
||||||
- `POST /api/crypto/encrypt-summary`
|
- `POST /api/crypto/encrypt-summary`
|
||||||
- 操作名:`encryptSummary`
|
- operationId: `encryptSummary`
|
||||||
- 说明:加密摘要并返回二维码内容
|
- 作用:基于 HKDF + AES-GCM 加密任意明文
|
||||||
|
|
||||||
- `POST /api/crypto/sign-and-pack-report`
|
- `POST /api/crypto/sign-and-pack-report`
|
||||||
- 操作名:`signAndPackReport`
|
- operationId: `signAndPackReport`
|
||||||
- 说明:上传原始 ZIP,返回签名后 ZIP
|
- 作用:上传原始 ZIP,生成 `summary.json` + `META-INF/manifest.json` + `META-INF/signature.asc`,返回签名后 ZIP 文件(二进制)
|
||||||
|
|
||||||
### Task
|
## 4. 文件上传接口(signAndPackReport)
|
||||||
|
|
||||||
- `POST /api/task/save`
|
调用顺序建议:
|
||||||
- 操作名:`taskSave`
|
|
||||||
- `POST /api/task/list`
|
|
||||||
- 操作名:`taskList`
|
|
||||||
- `POST /api/task/update-status`
|
|
||||||
- 操作名:`taskUpdateStatus`
|
|
||||||
|
|
||||||
## 4. 字段说明来源
|
1. 先调用 `POST /api/config/set-licence` 写入本地 `licence`
|
||||||
|
2. 调用 `POST /api/config/get` 确认 `fingerprint` 已存在
|
||||||
|
3. 再调用各 `crypto/*` 能力接口
|
||||||
|
|
||||||
每个接口的字段定义、必填/可选、类型、以及业务描述,均在 OpenAPI 中由 oRPC 合约的 Zod schema 自动生成。
|
请求使用 `multipart/form-data`:
|
||||||
|
|
||||||
你可以在 `/api/docs` 页面直接查看:
|
- `rawZip`:原始 ZIP 文件(`application/zip` / `application/x-zip-compressed`)
|
||||||
|
- `pgpPrivateKey`:签名材料
|
||||||
|
- `signingContext`:签名上下文字符串
|
||||||
|
- `summaryJson`:`summary.json` 的 JSON 文本
|
||||||
|
- `outputFileName`:可选,输出文件名
|
||||||
|
|
||||||
1. 接口名称(operationId)
|
响应:
|
||||||
2. 接口摘要(summary)
|
|
||||||
3. 详细说明(description)
|
|
||||||
4. 请求字段(含描述)
|
|
||||||
5. 响应字段(含描述)
|
|
||||||
|
|
||||||
## 5. 文件上传接口注意事项
|
- `application/zip` 二进制文件(签名后 ZIP)
|
||||||
|
|
||||||
`signAndPackReport` 使用 `multipart/form-data`,文件字段名为 `rawZip`。
|
说明:响应 ZIP 中 `summary.json` 会包含 `timestamp`(服务端生成,毫秒时间戳)。
|
||||||
|
|
||||||
- 文件类型:`application/zip` 或 `application/x-zip-compressed`
|
|
||||||
- 其他业务字段(如 `deviceId`、`taskId`)与文件一起提交
|
|
||||||
- 接口响应为签名后 ZIP 文件(`application/zip`)
|
|
||||||
|
|
||||||
示例(curl):
|
示例(curl):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "http://localhost:3000/api/crypto/sign-and-pack-report" \
|
curl -X POST "http://localhost:3000/api/crypto/sign-and-pack-report" \
|
||||||
-F "deviceId=dev_xxx" \
|
-F "pgpPrivateKey=-----BEGIN PGP PRIVATE KEY BLOCK-----..." \
|
||||||
-F "taskId=TASK-20260115-4875" \
|
-F "signingContext=TASK-20260115-4875|702286470691215417" \
|
||||||
-F "enterpriseId=1173040813421105152" \
|
-F "summaryJson={\"summary\":\"检查摘要\"}" \
|
||||||
-F "inspectionId=702286470691215417" \
|
-F "outputFileName=signed-report.zip" \
|
||||||
-F "summary=检查摘要信息" \
|
|
||||||
-F "rawZip=@./report-raw.zip;type=application/zip" \
|
-F "rawZip=@./report-raw.zip;type=application/zip" \
|
||||||
--output signed-report.zip
|
--output signed-report.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. 推荐接入方式
|
## 5. 字段说明来源
|
||||||
|
|
||||||
第三方如需代码生成,建议直接消费 `/api/spec.json`:
|
所有接口名称、字段类型、必填项、描述均以 `/api/docs` 与 `/api/spec.json` 为准。
|
||||||
|
|
||||||
- Java:OpenAPI Generator
|
|
||||||
- C#:NSwag / OpenAPI Generator
|
|
||||||
- TypeScript:openapi-typescript / Orval
|
|
||||||
|
|||||||
Reference in New Issue
Block a user