156 lines
4.4 KiB
TypeScript
156 lines
4.4 KiB
TypeScript
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<string>("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<string>("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<string>("DATA_ENCRYPTION_SECRET");
|
|
if (!secret) {
|
|
throw new InternalServerErrorException(
|
|
"服务端未配置 DATA_ENCRYPTION_SECRET,无法写入加密数据"
|
|
);
|
|
}
|
|
|
|
return createHash("sha256").update(secret, "utf8").digest();
|
|
}
|
|
}
|