refactor(server): 重构为本地身份配置 + 底层 crypto 能力接口

This commit is contained in:
2026-03-06 10:02:26 +08:00
parent 46e2c94faf
commit b50d2eaf10
13 changed files with 222 additions and 417 deletions

View 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)

View File

@@ -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('平台公钥Base64SPKI 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 与 fingerprintUTF-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:
'接收原始 ZIPmultipart/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'])

View File

@@ -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('平台公钥Base64SPKI 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('平台公钥Base64SPKI 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)

View File

@@ -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

View File

@@ -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)

View 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,
}
})

View File

@@ -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',
}) })
}) })

View File

@@ -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
})

View File

@@ -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,
}) })

View File

@@ -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
})

View File

@@ -1,2 +1,3 @@
export * from './device' export * from './device'
export * from './task' export * from './task'
export * from './ux-config'

View 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(),
})

View 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]
}