308 lines
9.1 KiB
TypeScript
308 lines
9.1 KiB
TypeScript
import {
|
|
aesGcmDecrypt,
|
|
aesGcmEncrypt,
|
|
hkdfSha256,
|
|
hmacSha256Base64,
|
|
pgpSignDetached,
|
|
rsaOaepEncrypt,
|
|
sha256,
|
|
sha256Hex,
|
|
} from '@furtherverse/crypto'
|
|
import { ORPCError } from '@orpc/server'
|
|
import type { JSZipObject } from 'jszip'
|
|
import JSZip from 'jszip'
|
|
import { z } from 'zod'
|
|
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
|
|
}
|
|
|
|
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 normalizePath = (name: string): string => name.replaceAll('\\', '/')
|
|
|
|
const isUnsafePath = (name: string): boolean => {
|
|
const normalized = normalizePath(name)
|
|
const segments = normalized.split('/')
|
|
|
|
return (
|
|
normalized.startsWith('/') ||
|
|
normalized.includes('\u0000') ||
|
|
segments.some((segment) => segment === '..' || segment.trim().length === 0)
|
|
)
|
|
}
|
|
|
|
const getBaseName = (name: string): string => {
|
|
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[]
|
|
|
|
if (entries.length > MAX_ZIP_ENTRIES) {
|
|
throw new ORPCError('BAD_REQUEST', {
|
|
message: `Zip contains too many entries: ${entries.length}`,
|
|
})
|
|
}
|
|
|
|
let totalUncompressedBytes = 0
|
|
|
|
for (const entry of entries) {
|
|
if (entry.dir) {
|
|
continue
|
|
}
|
|
|
|
if (isUnsafePath(entry.name)) {
|
|
throw new ORPCError('BAD_REQUEST', {
|
|
message: `Zip contains unsafe entry path: ${entry.name}`,
|
|
})
|
|
}
|
|
|
|
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}`,
|
|
})
|
|
}
|
|
|
|
totalUncompressedBytes += content.byteLength
|
|
if (totalUncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) {
|
|
throw new ORPCError('BAD_REQUEST', {
|
|
message: 'Zip total uncompressed content exceeds max size limit',
|
|
})
|
|
}
|
|
|
|
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',
|
|
})
|
|
}
|
|
|
|
return {
|
|
assets,
|
|
vulnerabilities,
|
|
weakPasswords,
|
|
reportHtml,
|
|
reportHtmlName,
|
|
}
|
|
}
|
|
|
|
const getDevice = async (
|
|
context: {
|
|
db: { query: { deviceTable: { findFirst: (args: { where: { id: string } }) => Promise<DeviceRow | undefined> } } }
|
|
},
|
|
deviceId: string,
|
|
): Promise<DeviceRow> => {
|
|
const device = await context.db.query.deviceTable.findFirst({
|
|
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 }) => {
|
|
const device = await getDevice(context, input.deviceId)
|
|
|
|
const deviceInfoJson = JSON.stringify({
|
|
licence: device.licence,
|
|
fingerprint: device.fingerprint,
|
|
})
|
|
|
|
const encrypted = rsaOaepEncrypt(deviceInfoJson, device.platformPublicKey)
|
|
|
|
return { encrypted }
|
|
})
|
|
|
|
export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ context, input }) => {
|
|
const device = await getDevice(context, input.deviceId)
|
|
|
|
const key = sha256(device.licence + device.fingerprint)
|
|
const decryptedJson = aesGcmDecrypt(input.encryptedData, key)
|
|
const taskData = taskPayloadSchema.parse(JSON.parse(decryptedJson))
|
|
|
|
return taskData
|
|
})
|
|
|
|
export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({ context, input }) => {
|
|
const device = await getDevice(context, input.deviceId)
|
|
|
|
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 }
|
|
})
|
|
|
|
export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(async ({ context, input }) => {
|
|
const device = await getDevice(context, input.deviceId)
|
|
const rawZipArrayBuffer = await input.rawZip.arrayBuffer()
|
|
const rawZipBytes = Buffer.from(rawZipArrayBuffer)
|
|
|
|
if (rawZipBytes.byteLength === 0 || rawZipBytes.byteLength > MAX_RAW_ZIP_BYTES) {
|
|
throw new ORPCError('BAD_REQUEST', {
|
|
message: 'rawZip is empty or exceeds max size limit',
|
|
})
|
|
}
|
|
|
|
const rawZip = await JSZip.loadAsync(rawZipBytes, {
|
|
checkCRC32: true,
|
|
}).catch(() => {
|
|
throw new ORPCError('BAD_REQUEST', {
|
|
message: 'rawZip is not a valid zip file',
|
|
})
|
|
})
|
|
|
|
const reportFiles = await getRequiredReportFiles(rawZip)
|
|
|
|
const ikm = device.licence + device.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 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 summaryObject = {
|
|
enterpriseId: input.enterpriseId,
|
|
inspectionId: input.inspectionId,
|
|
taskId: input.taskId,
|
|
licence: device.licence,
|
|
fingerprint: device.fingerprint,
|
|
deviceSignature,
|
|
summary: input.summary,
|
|
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 manifestBytes = Buffer.from(JSON.stringify(manifestObject, null, 2), 'utf-8')
|
|
const signatureAsc = await pgpSignDetached(manifestBytes, device.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)
|
|
signedZip.file('META-INF/manifest.json', manifestBytes)
|
|
signedZip.file('META-INF/signature.asc', signatureAsc)
|
|
|
|
const signedZipBytes = await signedZip.generateAsync({
|
|
type: 'uint8array',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 9 },
|
|
})
|
|
|
|
return new File([Buffer.from(signedZipBytes)], `${input.taskId}-signed-report.zip`, {
|
|
type: 'application/zip',
|
|
})
|
|
})
|