From b50d2eaf10c82bf539c5994bd0668759fdaaa447 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Fri, 6 Mar 2026 10:02:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor(server):=20=E9=87=8D=E6=9E=84=E4=B8=BA?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=BA=AB=E4=BB=BD=E9=85=8D=E7=BD=AE=20+=20?= =?UTF-8?q?=E5=BA=95=E5=B1=82=20crypto=20=E8=83=BD=E5=8A=9B=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/api/contracts/config.contract.ts | 35 +++ .../server/api/contracts/crypto.contract.ts | 47 ++-- .../server/api/contracts/device.contract.ts | 46 --- apps/server/src/server/api/contracts/index.ts | 6 +- .../src/server/api/contracts/task.contract.ts | 71 ----- .../src/server/api/routers/config.router.ts | 19 ++ .../src/server/api/routers/crypto.router.ts | 261 +++++++----------- .../src/server/api/routers/device.router.ts | 54 ---- apps/server/src/server/api/routers/index.ts | 6 +- .../src/server/api/routers/task.router.ts | 44 --- apps/server/src/server/db/schema/index.ts | 1 + apps/server/src/server/db/schema/ux-config.ts | 9 + apps/server/src/server/ux-config.ts | 40 +++ 13 files changed, 222 insertions(+), 417 deletions(-) create mode 100644 apps/server/src/server/api/contracts/config.contract.ts delete mode 100644 apps/server/src/server/api/contracts/device.contract.ts delete mode 100644 apps/server/src/server/api/contracts/task.contract.ts create mode 100644 apps/server/src/server/api/routers/config.router.ts delete mode 100644 apps/server/src/server/api/routers/device.router.ts delete mode 100644 apps/server/src/server/api/routers/task.router.ts create mode 100644 apps/server/src/server/db/schema/ux-config.ts create mode 100644 apps/server/src/server/ux-config.ts diff --git a/apps/server/src/server/api/contracts/config.contract.ts b/apps/server/src/server/api/contracts/config.contract.ts new file mode 100644 index 0000000..0d79be6 --- /dev/null +++ b/apps/server/src/server/api/contracts/config.contract.ts @@ -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) diff --git a/apps/server/src/server/api/contracts/crypto.contract.ts b/apps/server/src/server/api/contracts/crypto.contract.ts index 1cb7d26..0650f39 100644 --- a/apps/server/src/server/api/contracts/crypto.contract.ts +++ b/apps/server/src/server/api/contracts/crypto.contract.ts @@ -6,14 +6,14 @@ export const encryptDeviceInfo = oc method: 'POST', path: '/crypto/encrypt-device-info', operationId: 'encryptDeviceInfo', - summary: '生成设备授权密文', + summary: '加密设备信息', description: - '按 deviceId 查询已注册设备,使用设备记录中的 platformPublicKey 对 {licence, fingerprint} 做 RSA-OAEP 加密,返回 Base64 密文。', + '读取 UX 本地存储的 licence 与 fingerprint,组装为 UTF-8 JSON 字符串后,使用平台公钥执行 RSA-OAEP 加密并返回 Base64 密文。', tags: ['Crypto'], }) .input( z.object({ - deviceId: z.string().min(1).describe('设备 ID'), + platformPublicKey: z.string().min(1).describe('平台公钥(Base64,SPKI DER)'), }), ) .output( @@ -27,25 +27,19 @@ export const decryptTask = oc method: 'POST', path: '/crypto/decrypt-task', operationId: 'decryptTask', - summary: '解密任务二维码', + summary: '解密 AES-GCM 密文', description: - '按 deviceId 查询已注册设备,使用 UTF-8 编码下的 licence 与 fingerprint 直接拼接(无分隔符)后取 SHA256 作为 AES-256-GCM 密钥解密任务密文。', + '读取 UX 本地存储的 licence 与 fingerprint,UTF-8 直接拼接(无分隔符)后取 SHA256 作为 AES-256-GCM 密钥,解密 Base64 密文。', tags: ['Crypto'], }) .input( z.object({ - deviceId: z.string().min(1).describe('设备 ID'), - encryptedData: z.string().min(1).describe('任务二维码中的 Base64 密文'), + encryptedData: z.string().min(1).describe('Base64 密文'), }), ) .output( z.object({ - taskId: z.string().describe('任务 ID'), - enterpriseId: z.string().describe('企业 ID'), - orgName: z.string().describe('单位名称'), - inspectionId: z.string().describe('检查 ID'), - inspectionPerson: z.string().describe('检查人'), - issuedAt: z.number().describe('任务发布时间戳(毫秒)'), + decrypted: z.string().describe('解密后的 UTF-8 明文字符串'), }), ) @@ -54,22 +48,20 @@ export const encryptSummary = oc method: 'POST', path: '/crypto/encrypt-summary', operationId: 'encryptSummary', - summary: '加密摘要二维码内容', - description: '按 deviceId 查询已注册设备,使用 HKDF-SHA256 + AES-256-GCM 加密摘要信息,返回二维码 JSON 字符串。', + summary: '加密文本内容', + description: + '读取 UX 本地存储的 licence 与 fingerprint,使用 HKDF-SHA256 + AES-256-GCM 加密明文文本,返回 Base64 密文。', tags: ['Crypto'], }) .input( z.object({ - deviceId: z.string().min(1).describe('设备 ID'), - taskId: z.string().min(1).describe('任务 ID'), - enterpriseId: z.string().min(1).describe('企业 ID'), - inspectionId: z.string().min(1).describe('检查 ID'), - summary: z.string().min(1).describe('摘要明文'), + salt: z.string().min(1).describe('HKDF salt(例如 taskId)'), + plaintext: z.string().min(1).describe('待加密明文'), }), ) .output( z.object({ - qrContent: z.string().describe('二维码内容 JSON:{"taskId":"...","encrypted":"..."}'), + encrypted: z.string().describe('Base64 密文'), }), ) @@ -78,18 +70,17 @@ export const signAndPackReport = oc method: 'POST', path: '/crypto/sign-and-pack-report', operationId: 'signAndPackReport', - summary: '签名并打包报告 ZIP', + summary: '签名并打包 ZIP', 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'], }) .input( z.object({ - deviceId: z.string().min(1).describe('设备 ID'), - taskId: z.string().min(1).describe('任务 ID'), - enterpriseId: z.string().min(1).describe('企业 ID'), - inspectionId: z.string().min(1).describe('检查 ID'), - summary: z.string().min(1).describe('检查摘要明文'), + pgpPrivateKey: z.string().min(1).describe('OpenPGP 私钥(ASCII armored)'), + signingContext: z.string().min(1).describe('签名上下文字符串(由调用方定义)'), + summaryJson: z.string().min(1).describe('summary.json 的完整 JSON 文本'), + outputFileName: z.string().min(1).optional().describe('返回 ZIP 文件名(可选)'), rawZip: z .file() .mime(['application/zip', 'application/x-zip-compressed']) diff --git a/apps/server/src/server/api/contracts/device.contract.ts b/apps/server/src/server/api/contracts/device.contract.ts deleted file mode 100644 index 666b72c..0000000 --- a/apps/server/src/server/api/contracts/device.contract.ts +++ /dev/null @@ -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) diff --git a/apps/server/src/server/api/contracts/index.ts b/apps/server/src/server/api/contracts/index.ts index c46ca45..c666437 100644 --- a/apps/server/src/server/api/contracts/index.ts +++ b/apps/server/src/server/api/contracts/index.ts @@ -1,11 +1,9 @@ +import * as config from './config.contract' import * as crypto from './crypto.contract' -import * as device from './device.contract' -import * as task from './task.contract' export const contract = { - device, + config, crypto, - task, } export type Contract = typeof contract diff --git a/apps/server/src/server/api/contracts/task.contract.ts b/apps/server/src/server/api/contracts/task.contract.ts deleted file mode 100644 index 5fc3dc4..0000000 --- a/apps/server/src/server/api/contracts/task.contract.ts +++ /dev/null @@ -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) diff --git a/apps/server/src/server/api/routers/config.router.ts b/apps/server/src/server/api/routers/config.router.ts new file mode 100644 index 0000000..da2cb60 --- /dev/null +++ b/apps/server/src/server/api/routers/config.router.ts @@ -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, + } +}) diff --git a/apps/server/src/server/api/routers/crypto.router.ts b/apps/server/src/server/api/routers/crypto.router.ts index 551a94c..8a9c140 100644 --- a/apps/server/src/server/api/routers/crypto.router.ts +++ b/apps/server/src/server/api/routers/crypto.router.ts @@ -11,40 +11,19 @@ import { import { ORPCError } from '@orpc/server' import type { JSZipObject } from 'jszip' import JSZip from 'jszip' -import { z } from 'zod' +import { getUxConfig } from '@/server/ux-config' import { db } from '../middlewares' import { os } from '../server' -interface DeviceRow { - id: string - licence: string - fingerprint: string - platformPublicKey: string - pgpPrivateKey: string | null - pgpPublicKey: string | null -} - -interface ReportFiles { - assets: Uint8Array - vulnerabilities: Uint8Array - weakPasswords: Uint8Array - reportHtml: Uint8Array - reportHtmlName: string +interface ZipFileItem { + name: string + bytes: Uint8Array } const MAX_RAW_ZIP_BYTES = 50 * 1024 * 1024 const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024 const MAX_TOTAL_UNCOMPRESSED_BYTES = 60 * 1024 * 1024 -const MAX_ZIP_ENTRIES = 32 - -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 MAX_ZIP_ENTRIES = 64 const normalizePath = (name: string): string => name.replaceAll('\\', '/') @@ -59,21 +38,8 @@ const isUnsafePath = (name: string): boolean => { ) } -const getBaseName = (name: string): string => { - const normalized = normalizePath(name) - const parts = normalized.split('/') - return parts.at(-1) ?? normalized -} - -const getRequiredReportFiles = async (rawZip: JSZip): Promise => { - 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 listSafeZipFiles = async (rawZip: JSZip): Promise => { const entries = Object.values(rawZip.files) as JSZipObject[] - if (entries.length > MAX_ZIP_ENTRIES) { throw new ORPCError('BAD_REQUEST', { message: `Zip contains too many entries: ${entries.length}`, @@ -81,6 +47,8 @@ const getRequiredReportFiles = async (rawZip: JSZip): Promise => { } let totalUncompressedBytes = 0 + const files: ZipFileItem[] = [] + const seen = new Set() for (const entry of entries) { if (entry.dir) { @@ -93,10 +61,18 @@ const getRequiredReportFiles = async (rawZip: JSZip): Promise => { }) } + 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') if (content.byteLength > MAX_SINGLE_FILE_BYTES) { 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 => { }) } - const fileName = getBaseName(entry.name) - const lowerFileName = fileName.toLowerCase() - - 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', + files.push({ + name: normalizedName, + bytes: content, }) } - return { - assets, - vulnerabilities, - weakPasswords, - reportHtml, - reportHtmlName, + if (files.length === 0) { + throw new ORPCError('BAD_REQUEST', { + message: 'Zip has no file entries', + }) } + + return files } -const getDevice = async ( - context: { - db: { query: { deviceTable: { findFirst: (args: { where: { id: string } }) => Promise } } } - }, - deviceId: string, -): Promise => { - const device = await context.db.query.deviceTable.findFirst({ - where: { id: deviceId }, - }) - if (!device) { - throw new ORPCError('NOT_FOUND', { message: 'Device not found' }) +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'}`, + }) } - return device } 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({ - licence: device.licence, - fingerprint: device.fingerprint, + licence: config.licence, + fingerprint: config.fingerprint, }) - const encrypted = rsaOaepEncrypt(deviceInfoJson, device.platformPublicKey) - + const encrypted = rsaOaepEncrypt(deviceInfoJson, input.platformPublicKey) return { encrypted } }) 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 decryptedJson = aesGcmDecrypt(input.encryptedData, key) - const taskData = taskPayloadSchema.parse(JSON.parse(decryptedJson)) - - return taskData + const key = sha256(config.licence + config.fingerprint) + const decrypted = aesGcmDecrypt(input.encryptedData, key) + return { decrypted } }) 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) { + throw new ORPCError('PRECONDITION_FAILED', { + message: 'Local identity is not initialized. Call config.get and then config.setLicence first.', + }) + } - const ikm = device.licence + device.fingerprint - const aesKey = hkdfSha256(ikm, input.taskId, 'inspection_report_encryption') - - const timestamp = Date.now() - const plaintextJson = JSON.stringify({ - enterpriseId: input.enterpriseId, - inspectionId: input.inspectionId, - summary: input.summary, - timestamp, - }) - - const encrypted = aesGcmEncrypt(plaintextJson, aesKey) - - const qrContent = JSON.stringify({ - taskId: input.taskId, - encrypted, - }) - - return { qrContent } + const ikm = config.licence + config.fingerprint + const aesKey = hkdfSha256(ikm, input.salt, 'inspection_report_encryption') + const encrypted = aesGcmEncrypt(input.plaintext, aesKey) + return { encrypted } }) 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 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 assetsHash = sha256Hex(Buffer.from(reportFiles.assets)) - const vulnerabilitiesHash = sha256Hex(Buffer.from(reportFiles.vulnerabilities)) - const weakPasswordsHash = sha256Hex(Buffer.from(reportFiles.weakPasswords)) - const reportHtmlHash = sha256Hex(Buffer.from(reportFiles.reportHtml)) - - const signPayload = - input.taskId + input.inspectionId + assetsHash + vulnerabilitiesHash + weakPasswordsHash + reportHtmlHash + const fileHashEntries = zipFiles + .map((item) => ({ + name: item.name, + hash: sha256Hex(Buffer.from(item.bytes)), + })) + .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 deviceSignature = hmacSha256Base64(signingKey, signPayload) - if (!device.pgpPrivateKey) { - throw new ORPCError('PRECONDITION_FAILED', { - message: 'Device does not have a PGP key pair. Re-register the device.', - }) - } - + const normalizedSummaryJson = parseSummaryJsonOrThrow(input.summaryJson) const summaryObject = { - enterpriseId: input.enterpriseId, - inspectionId: input.inspectionId, - taskId: input.taskId, - licence: device.licence, - fingerprint: device.fingerprint, deviceSignature, - summary: input.summary, + signingContext: input.signingContext, + payload: JSON.parse(normalizedSummaryJson), timestamp: Date.now(), } - const summaryBytes = Buffer.from(JSON.stringify(summaryObject), 'utf-8') - const manifestObject = { - files: { - 'summary.json': sha256Hex(summaryBytes), - 'assets.json': assetsHash, - 'vulnerabilities.json': vulnerabilitiesHash, - 'weakPasswords.json': weakPasswordsHash, - [reportFiles.reportHtmlName]: reportHtmlHash, - }, + const manifestFiles: Record = { + 'summary.json': sha256Hex(summaryBytes), + } + for (const item of fileHashEntries) { + 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, device.pgpPrivateKey) + const signatureAsc = await pgpSignDetached(manifestBytes, input.pgpPrivateKey) const signedZip = new JSZip() signedZip.file('summary.json', summaryBytes) - signedZip.file('assets.json', reportFiles.assets) - signedZip.file('vulnerabilities.json', reportFiles.vulnerabilities) - signedZip.file('weakPasswords.json', reportFiles.weakPasswords) - signedZip.file(reportFiles.reportHtmlName, reportFiles.reportHtml) + for (const item of zipFiles) { + signedZip.file(item.name, item.bytes) + } signedZip.file('META-INF/manifest.json', manifestBytes) signedZip.file('META-INF/signature.asc', signatureAsc) @@ -301,7 +230,7 @@ export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(asy 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', }) }) diff --git a/apps/server/src/server/api/routers/device.router.ts b/apps/server/src/server/api/routers/device.router.ts deleted file mode 100644 index 6f9633b..0000000 --- a/apps/server/src/server/api/routers/device.router.ts +++ /dev/null @@ -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 -}) diff --git a/apps/server/src/server/api/routers/index.ts b/apps/server/src/server/api/routers/index.ts index a93b77f..00fb054 100644 --- a/apps/server/src/server/api/routers/index.ts +++ b/apps/server/src/server/api/routers/index.ts @@ -1,10 +1,8 @@ import { os } from '../server' +import * as config from './config.router' import * as crypto from './crypto.router' -import * as device from './device.router' -import * as task from './task.router' export const router = os.router({ - device, + config, crypto, - task, }) diff --git a/apps/server/src/server/api/routers/task.router.ts b/apps/server/src/server/api/routers/task.router.ts deleted file mode 100644 index eb858a8..0000000 --- a/apps/server/src/server/api/routers/task.router.ts +++ /dev/null @@ -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 -}) diff --git a/apps/server/src/server/db/schema/index.ts b/apps/server/src/server/db/schema/index.ts index 9808e7c..e97c965 100644 --- a/apps/server/src/server/db/schema/index.ts +++ b/apps/server/src/server/db/schema/index.ts @@ -1,2 +1,3 @@ export * from './device' export * from './task' +export * from './ux-config' diff --git a/apps/server/src/server/db/schema/ux-config.ts b/apps/server/src/server/db/schema/ux-config.ts new file mode 100644 index 0000000..42ad91a --- /dev/null +++ b/apps/server/src/server/db/schema/ux-config.ts @@ -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(), +}) diff --git a/apps/server/src/server/ux-config.ts b/apps/server/src/server/ux-config.ts new file mode 100644 index 0000000..1007e65 --- /dev/null +++ b/apps/server/src/server/ux-config.ts @@ -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] +}