import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Prisma } from "../../generated/prisma/client"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; const ENCRYPTION_PREFIX = "encv1"; const ENCRYPTION_ALGORITHM = "aes-256-gcm"; const ENCRYPTION_IV_LENGTH = 12; @Injectable() export class DataEncryptionService { constructor(private readonly configService: ConfigService) {} isConfigured(): boolean { return Boolean(this.configService.get("DATA_ENCRYPTION_SECRET")); } isEncryptedString(value: string): boolean { return value.startsWith(`${ENCRYPTION_PREFIX}:`); } encryptString(value: string | null | undefined): string | null | undefined { if (value === undefined) { return undefined; } if (value === null) { return null; } const key = this.resolveKey(); const iv = randomBytes(ENCRYPTION_IV_LENGTH); const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv); const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); return [ ENCRYPTION_PREFIX, iv.toString("base64url"), authTag.toString("base64url"), encrypted.toString("base64url") ].join(":"); } decryptString(value: string | null | undefined): string | null | undefined { if (value === undefined) { return undefined; } if (value === null || !this.isEncryptedPayload(value)) { return value; } const [prefix, ivText, authTagText, encryptedText] = value.split(":"); if (prefix !== ENCRYPTION_PREFIX || !ivText || !authTagText || encryptedText === undefined) { throw new InternalServerErrorException("加密数据格式无效"); } try { const key = this.resolveKey(); const decipher = createDecipheriv( ENCRYPTION_ALGORITHM, key, Buffer.from(ivText, "base64url") ); decipher.setAuthTag(Buffer.from(authTagText, "base64url")); const decrypted = Buffer.concat([ decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final() ]); return decrypted.toString("utf8"); } catch { throw new InternalServerErrorException("加密数据解密失败"); } } encryptJson( value: Prisma.InputJsonValue | null | undefined ): Prisma.InputJsonValue | null | undefined { if (value === undefined) { return undefined; } if (value === null) { return null; } return this.encryptString(JSON.stringify(value)); } decryptJson(value: Prisma.JsonValue | null): Prisma.JsonValue | null { if (value === null) { return null; } if (typeof value !== "string" || !this.isEncryptedPayload(value)) { return value; } const decrypted = this.decryptString(value); if (typeof decrypted !== "string") { throw new InternalServerErrorException("加密数据解密失败"); } try { return JSON.parse(decrypted) as Prisma.JsonValue; } catch { throw new InternalServerErrorException("加密 JSON 数据损坏"); } } decryptPayload(value: Prisma.JsonValue | null): string | null { if (value === null) { return null; } if (typeof value === "string") { return this.decryptString(value) ?? null; } return JSON.stringify(value); } createLookupHash(scope: string, value: string): string { const normalizedScope = scope.trim().toLowerCase(); if (!normalizedScope) { throw new InternalServerErrorException("缺少盲索引作用域"); } const secret = this.configService.get("DATA_ENCRYPTION_SECRET"); if (!secret) { throw new InternalServerErrorException("服务端未配置 DATA_ENCRYPTION_SECRET,无法生成盲索引"); } return createHmac("sha256", `lookup:${normalizedScope}:${secret}`) .update(value, "utf8") .digest("hex"); } private isEncryptedPayload(value: string): boolean { return this.isEncryptedString(value); } private resolveKey(): Buffer { const secret = this.configService.get("DATA_ENCRYPTION_SECRET"); if (!secret) { throw new InternalServerErrorException( "服务端未配置 DATA_ENCRYPTION_SECRET,无法写入加密数据" ); } return createHash("sha256").update(secret, "utf8").digest(); } }