From 13abfc1e52fb2bc879dc338dcefbf135c737ba6c Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Mon, 6 Apr 2026 15:25:10 +0800 Subject: [PATCH] feat(api-security): encrypt sensitive data at rest --- apps/api/.env.example | 8 + apps/api/package.json | 3 +- apps/api/scripts/reencrypt-sensitive-data.ts | 297 ++++++++++++++++++ apps/api/src/ai/ai.service.ts | 96 ++++-- apps/api/src/app.module.ts | 2 + apps/api/src/attachment/attachment.service.ts | 91 ++++-- .../src/security/data-encryption.service.ts | 139 ++++++++ apps/api/src/security/security.module.ts | 9 + apps/api/src/sync/sync.service.ts | 18 +- apps/api/src/task/task.service.ts | 96 +++++- apps/api/test/ai.spec.ts | 19 ++ apps/api/test/sync-push.spec.ts | 22 +- apps/api/test/task.spec.ts | 19 +- 13 files changed, 739 insertions(+), 80 deletions(-) create mode 100644 apps/api/scripts/reencrypt-sensitive-data.ts create mode 100644 apps/api/src/security/data-encryption.service.ts create mode 100644 apps/api/src/security/security.module.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 971b829..c8045bf 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -63,3 +63,11 @@ MAIL_SMTP_PASS="replace-with-smtp-password" # 发件人显示名称与地址 MAIL_FROM_NAME="TodoList" MAIL_FROM_ADDRESS="no-reply@example.com" + +# [数据加密] 服务端敏感数据加密主密钥 +# 用于加密 AI 配置、任务内容、同步 payload、附件元数据等数据库字段 +# 请使用高强度随机字符串,生产环境务必单独保管 +DATA_ENCRYPTION_SECRET="replace-with-a-long-random-secret" + +# [对象存储加密] 服务端对象加密策略,默认使用 AES256;如需关闭可填写 NONE +S3_SERVER_SIDE_ENCRYPTION="AES256" diff --git a/apps/api/package.json b/apps/api/package.json index f2d2dff..642f983 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,12 +3,13 @@ "version": "0.1.0", "description": "TodoList API service", "scripts": { - "prisma:generate": "prisma generate", + "prisma:generate": "node -e \"require('node:fs').rmSync('generated/prisma', { recursive: true, force: true })\" && prisma generate", "prisma:format": "prisma format", "prisma:validate": "prisma validate", "prebuild": "pnpm run prisma:generate", "pretypecheck": "pnpm run prisma:generate", "pretest": "pnpm run prisma:generate", + "data:reencrypt": "ts-node scripts/reencrypt-sensitive-data.ts", "start": "node dist/main.js", "start:dev": "ts-node-dev --respawn --transpile-only src/main.ts", "build": "tsc -p tsconfig.build.json", diff --git a/apps/api/scripts/reencrypt-sensitive-data.ts b/apps/api/scripts/reencrypt-sensitive-data.ts new file mode 100644 index 0000000..1b6e41b --- /dev/null +++ b/apps/api/scripts/reencrypt-sensitive-data.ts @@ -0,0 +1,297 @@ +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { ConfigService } from "@nestjs/config"; +import { Prisma, PrismaClient } from "../generated/prisma/client"; +import { DataEncryptionService } from "../src/security/data-encryption.service"; + +type MigrationCounter = Record< + "aiBindings" | "publicPools" | "tasks" | "attachments" | "syncOperations", + number +>; + +function createEncryptionService(): DataEncryptionService { + const configService = { + get: (key: string) => process.env[key] + } as ConfigService; + + return new DataEncryptionService(configService); +} + +function encryptStringIfNeeded( + value: string | null, + dataEncryptionService: DataEncryptionService +): string | null | undefined { + if (value === null || dataEncryptionService.isEncryptedString(value)) { + return undefined; + } + + return dataEncryptionService.encryptString(value) ?? null; +} + +function encryptJsonIfNeeded( + value: Prisma.JsonValue | null, + dataEncryptionService: DataEncryptionService +): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined { + if (value === null) { + return undefined; + } + + if (typeof value === "string" && dataEncryptionService.isEncryptedString(value)) { + return undefined; + } + + return (dataEncryptionService.encryptJson(value as Prisma.InputJsonValue) ?? Prisma.JsonNull) as + | Prisma.InputJsonValue + | Prisma.NullableJsonNullValueInput; +} + +async function main(): Promise { + if (!process.env["DATABASE_URL"]) { + throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移"); + } + + if (!process.env["DATA_ENCRYPTION_SECRET"]) { + throw new Error("缺少 DATA_ENCRYPTION_SECRET,无法执行敏感数据迁移"); + } + + const prisma = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: process.env["DATABASE_URL"] + }) + }); + const dataEncryptionService = createEncryptionService(); + const counter: MigrationCounter = { + aiBindings: 0, + publicPools: 0, + tasks: 0, + attachments: 0, + syncOperations: 0 + }; + + try { + const aiBindings = await prisma.aiProviderBinding.findMany({ + select: { + id: true, + providerName: true, + model: true, + configId: true, + configName: true, + endpoint: true, + encryptedApiKey: true + } + }); + + for (const binding of aiBindings) { + const data: Prisma.AiProviderBindingUpdateInput = {}; + const providerName = encryptStringIfNeeded(binding.providerName, dataEncryptionService); + const model = encryptStringIfNeeded(binding.model, dataEncryptionService); + const configId = encryptStringIfNeeded(binding.configId, dataEncryptionService); + const configName = encryptStringIfNeeded(binding.configName, dataEncryptionService); + const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService); + const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService); + + if (providerName !== undefined) { + data.providerName = providerName; + } + if (model !== undefined) { + data.model = model; + } + if (configId !== undefined) { + data.configId = configId; + } + if (configName !== undefined) { + data.configName = configName; + } + if (endpoint !== undefined) { + data.endpoint = endpoint; + } + if (encryptedApiKey !== undefined) { + data.encryptedApiKey = encryptedApiKey; + } + + if (Object.keys(data).length === 0) { + continue; + } + + await prisma.aiProviderBinding.update({ + where: { + id: binding.id + }, + data + }); + counter.aiBindings += 1; + } + + const publicPools = await prisma.aiPublicPoolConfig.findMany({ + select: { + id: true, + providerName: true, + model: true, + endpoint: true, + encryptedApiKey: true + } + }); + + for (const publicPool of publicPools) { + const data: Prisma.AiPublicPoolConfigUpdateInput = {}; + const providerName = encryptStringIfNeeded(publicPool.providerName, dataEncryptionService); + const model = encryptStringIfNeeded(publicPool.model, dataEncryptionService); + const endpoint = encryptStringIfNeeded(publicPool.endpoint, dataEncryptionService); + const encryptedApiKey = encryptStringIfNeeded( + publicPool.encryptedApiKey, + dataEncryptionService + ); + + if (providerName !== undefined) { + data.providerName = providerName; + } + if (model !== undefined) { + data.model = model; + } + if (endpoint !== undefined) { + data.endpoint = endpoint; + } + if (encryptedApiKey !== undefined) { + data.encryptedApiKey = encryptedApiKey; + } + + if (Object.keys(data).length === 0) { + continue; + } + + await prisma.aiPublicPoolConfig.update({ + where: { + id: publicPool.id + }, + data + }); + counter.publicPools += 1; + } + + const tasks = await prisma.task.findMany({ + select: { + id: true, + title: true, + contentJson: true, + contentText: true + } + }); + + for (const task of tasks) { + const data: Prisma.TaskUpdateInput = {}; + const title = encryptStringIfNeeded(task.title, dataEncryptionService); + const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService); + const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService); + + if (title !== undefined) { + data.title = title; + } + if (contentJson !== undefined) { + data.contentJson = contentJson; + } + if (contentText !== undefined) { + data.contentText = contentText; + } + + if (Object.keys(data).length === 0) { + continue; + } + + await prisma.task.update({ + where: { + id: task.id + }, + data + }); + counter.tasks += 1; + } + + const attachments = await prisma.attachment.findMany({ + select: { + id: true, + url: true, + fileName: true, + checksum: true + } + }); + + for (const attachment of attachments) { + const data: Prisma.AttachmentUpdateInput = {}; + const url = encryptStringIfNeeded(attachment.url, dataEncryptionService); + const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService); + const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService); + + if (url !== undefined) { + data.url = url; + } + if (fileName !== undefined) { + data.fileName = fileName; + } + if (checksum !== undefined) { + data.checksum = checksum; + } + + if (Object.keys(data).length === 0) { + continue; + } + + await prisma.attachment.update({ + where: { + id: attachment.id + }, + data + }); + counter.attachments += 1; + } + + const syncOperations = await prisma.syncOperation.findMany({ + select: { + id: true, + payload: true + } + }); + + for (const operation of syncOperations) { + if (operation.payload === null) { + continue; + } + + let nextPayload: string | null = null; + if (typeof operation.payload === "string") { + if (dataEncryptionService.isEncryptedString(operation.payload)) { + continue; + } + + nextPayload = dataEncryptionService.encryptString(operation.payload) ?? null; + } else { + nextPayload = + dataEncryptionService.encryptString(JSON.stringify(operation.payload)) ?? null; + } + + if (nextPayload === null) { + continue; + } + + await prisma.syncOperation.update({ + where: { + id: operation.id + }, + data: { + payload: nextPayload + } + }); + counter.syncOperations += 1; + } + + console.log("敏感数据迁移完成"); + console.log(JSON.stringify(counter, null, 2)); + } finally { + await prisma.$disconnect(); + } +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "未知错误"; + console.error(`敏感数据迁移失败:${message}`); + process.exitCode = 1; +}); diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index 8b57109..cbeef1d 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -9,6 +9,7 @@ import { TaskStatus } from "../../generated/prisma/client"; import { PrismaService } from "../prisma/prisma.service"; +import { DataEncryptionService } from "../security/data-encryption.service"; import { AiProviderRegistryService } from "./ai-provider-registry.service"; import { AiChatDto } from "./dto/ai-chat.dto"; import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto"; @@ -93,7 +94,8 @@ export class AiService { constructor( private readonly prismaService: PrismaService, - private readonly aiProviderRegistryService: AiProviderRegistryService + private readonly aiProviderRegistryService: AiProviderRegistryService, + private readonly dataEncryptionService: DataEncryptionService ) {} async listBindings(userId: string): Promise { @@ -119,8 +121,8 @@ export class AiService { publicPool: publicPool ? { enabled: publicPool.enabled, - providerName: publicPool.providerName, - model: publicPool.model, + providerName: this.readDecryptedString(publicPool.providerName), + model: this.readDecryptedString(publicPool.model), hasApiKey: Boolean(publicPool.encryptedApiKey) } : null @@ -191,12 +193,12 @@ export class AiService { data: { userId, channel: dto.channel, - providerName: this.normalizeProviderName(dto.providerName), - model: this.normalizeOptionalString(dto.model), - configId: this.normalizeOptionalString(dto.configId), - configName: this.normalizeOptionalString(dto.configName), - endpoint: this.normalizeOptionalString(dto.endpoint), - encryptedApiKey: this.normalizeOptionalString(dto.apiKey), + providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)), + model: this.encryptOptionalString(dto.model), + configId: this.encryptOptionalString(dto.configId), + configName: this.encryptOptionalString(dto.configName), + endpoint: this.encryptOptionalString(dto.endpoint), + encryptedApiKey: this.encryptOptionalString(dto.apiKey), isEnabled: dto.isEnabled ?? true } }); @@ -204,19 +206,19 @@ export class AiService { const updateData: Prisma.AiProviderBindingUpdateInput = { channel: dto.channel, - providerName: this.normalizeProviderName(dto.providerName), - model: this.normalizeOptionalString(dto.model), - configId: this.normalizeOptionalString(dto.configId), - configName: this.normalizeOptionalString(dto.configName), + providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)), + model: this.encryptOptionalString(dto.model), + configId: this.encryptOptionalString(dto.configId), + configName: this.encryptOptionalString(dto.configName), isEnabled: dto.isEnabled ?? existingBinding.isEnabled }; if (dto.endpoint !== undefined) { - updateData.endpoint = this.normalizeOptionalString(dto.endpoint); + updateData.endpoint = this.encryptOptionalString(dto.endpoint); } if (dto.apiKey !== undefined) { - updateData.encryptedApiKey = this.normalizeOptionalString(dto.apiKey); + updateData.encryptedApiKey = this.encryptOptionalString(dto.apiKey); } return tx.aiProviderBinding.update({ @@ -398,12 +400,12 @@ export class AiService { channel: binding.channel, source: "binding", sourceId: binding.id, - providerName: binding.providerName, - model: binding.model, - configId: binding.configId, - configName: binding.configName, - endpoint: binding.endpoint, - apiKey: binding.encryptedApiKey + providerName: this.readDecryptedString(binding.providerName) ?? "", + model: this.readDecryptedString(binding.model), + configId: this.readDecryptedString(binding.configId), + configName: this.readDecryptedString(binding.configName), + endpoint: this.readDecryptedString(binding.endpoint), + apiKey: this.readDecryptedString(binding.encryptedApiKey) }; } @@ -412,27 +414,34 @@ export class AiService { channel: AiChannel.PUBLIC_POOL, source: "public_pool", sourceId: publicPool.id, - providerName: publicPool.providerName ?? "public-pool", - model: publicPool.model, + providerName: this.readDecryptedString(publicPool.providerName) ?? "public-pool", + model: this.readDecryptedString(publicPool.model), configId: null, configName: null, - endpoint: publicPool.endpoint, - apiKey: publicPool.encryptedApiKey + endpoint: this.readDecryptedString(publicPool.endpoint), + apiKey: this.readDecryptedString(publicPool.encryptedApiKey) }; } private serializeBinding(binding: AiProviderBinding): AiBindingSummary { + const decryptedProviderName = this.readDecryptedString(binding.providerName) ?? ""; + const decryptedModel = this.readDecryptedString(binding.model); + const decryptedConfigId = this.readDecryptedString(binding.configId); + const decryptedConfigName = this.readDecryptedString(binding.configName); + const decryptedEndpoint = this.readDecryptedString(binding.endpoint); + const decryptedApiKey = this.readDecryptedString(binding.encryptedApiKey); + return { id: binding.id, channel: binding.channel, - providerName: binding.providerName, - model: binding.model, - configId: binding.configId, - configName: binding.configName, - endpoint: binding.endpoint, + providerName: decryptedProviderName, + model: decryptedModel, + configId: decryptedConfigId, + configName: decryptedConfigName, + endpoint: decryptedEndpoint, isEnabled: binding.isEnabled, hasApiKey: Boolean(binding.encryptedApiKey), - maskedApiKey: this.maskSecret(binding.encryptedApiKey), + maskedApiKey: this.maskSecret(decryptedApiKey), updatedAt: binding.updatedAt.toISOString() }; } @@ -523,14 +532,16 @@ export class AiService { const visibleTasks = sortedTasks.slice(0, this.maxContextTasks); const lines = visibleTasks.map((task, index) => { + const taskTitle = this.readDecryptedString(task.title) ?? "未命名任务"; + const contentText = this.readDecryptedString(task.contentText); const parts = [ - `${index + 1}. ${task.title}`, + `${index + 1}. ${taskTitle}`, `优先级:${this.getPriorityLabel(task.priority)}`, `状态:${this.getStatusLabel(task.status)}`, `DDL:${task.ddl ? task.ddl.toISOString() : "未设置"}` ]; - const contentSnippet = this.getContentSnippet(task.contentText); + const contentSnippet = this.getContentSnippet(contentText); if (contentSnippet) { parts.push(`内容摘要:${contentSnippet}`); } @@ -592,6 +603,25 @@ export class AiService { return this.normalizeOptionalString(value) ?? ""; } + private encryptOptionalString(value: string | undefined): string | null | undefined { + const normalizedValue = this.normalizeOptionalString(value); + return this.dataEncryptionService.encryptString(normalizedValue); + } + + private encryptRequiredString(value: string): string { + const encryptedValue = this.dataEncryptionService.encryptString(value); + if (!encryptedValue) { + throw new BadRequestException("敏感配置加密失败"); + } + + return encryptedValue; + } + + private readDecryptedString(value: string | null): string | null { + const decryptedValue = this.dataEncryptionService.decryptString(value); + return typeof decryptedValue === "string" ? decryptedValue : null; + } + private validateBindingInput(dto: UpsertAiProviderBindingDto): void { const providerName = this.normalizeOptionalString(dto.providerName); const configId = this.normalizeOptionalString(dto.configId); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index db0e7c4..175a3f5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { AiModule } from "./ai/ai.module"; import { AttachmentModule } from "./attachment/attachment.module"; import { AuthModule } from "./auth/auth.module"; import { PrismaModule } from "./prisma/prisma.module"; +import { SecurityModule } from "./security/security.module"; import { SyncModule } from "./sync/sync.module"; import { TaskModule } from "./task/task.module"; @@ -14,6 +15,7 @@ import { TaskModule } from "./task/task.module"; envFilePath: ".env" }), PrismaModule, + SecurityModule, AuthModule, TaskModule, AttachmentModule, diff --git a/apps/api/src/attachment/attachment.service.ts b/apps/api/src/attachment/attachment.service.ts index 031cd03..cb15f7e 100644 --- a/apps/api/src/attachment/attachment.service.ts +++ b/apps/api/src/attachment/attachment.service.ts @@ -1,10 +1,16 @@ import { randomUUID } from "node:crypto"; -import { Injectable, NotFoundException, PayloadTooLargeException } from "@nestjs/common"; +import { + Injectable, + InternalServerErrorException, + NotFoundException, + PayloadTooLargeException +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { AttachmentType } from "../../generated/prisma/client"; import { PrismaService } from "../prisma/prisma.service"; +import { DataEncryptionService } from "../security/data-encryption.service"; import { CompleteAttachmentDto } from "./dto/complete-attachment.dto"; import { PresignAttachmentDto } from "./dto/presign-attachment.dto"; @@ -25,9 +31,7 @@ export type PresignAttachmentResponse = { usedBytes: string; remainingBytes: string; }; - headers: { - "Content-Type": string; - }; + headers: Record; }; export type AttachmentResponse = { @@ -52,7 +56,8 @@ export class AttachmentService { constructor( private readonly configService: ConfigService, - private readonly prismaService: PrismaService + private readonly prismaService: PrismaService, + private readonly dataEncryptionService: DataEncryptionService ) {} async presignAttachment( @@ -67,15 +72,17 @@ export class AttachmentService { } const bucket = this.getDefaultBucket(); - const objectKey = this.generateObjectKey(userId, body.fileName); + const objectKey = this.generateObjectKey(body.fileName); const objectUrl = this.resolveObjectUrl(bucket, objectKey); const expiresInSeconds = this.getPresignExpiresInSeconds(); + const serverSideEncryption = this.getServerSideEncryptionMode(); const command = new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: body.mimeType, - ContentLength: body.fileSize + ContentLength: body.fileSize, + ServerSideEncryption: serverSideEncryption }); const uploadUrl = await getSignedUrl(this.getS3Client(), command, { @@ -94,9 +101,7 @@ export class AttachmentService { usedBytes: quotaInfo.usedBytes.toString(), remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString() }, - headers: { - "Content-Type": body.mimeType - } + headers: this.buildUploadHeaders(body.mimeType, serverSideEncryption) }; } @@ -139,14 +144,14 @@ export class AttachmentService { userId, taskId: body.taskId ?? null, type: body.type ?? this.resolveAttachmentType(body.mimeType), - url: objectUrl, + url: this.encryptRequiredString(objectUrl), mimeType: body.mimeType, - fileName: body.fileName, + fileName: this.encryptNullableString(body.fileName), fileSize: body.fileSize, width: body.width ?? null, height: body.height ?? null, durationMs: body.durationMs ?? null, - checksum: body.checksum ?? null + checksum: this.encryptNullableString(body.checksum) } }); }); @@ -155,14 +160,14 @@ export class AttachmentService { id: attachment.id, taskId: attachment.taskId, type: attachment.type, - url: attachment.url, + url: this.readDecryptedString(attachment.url) ?? objectUrl, mimeType: attachment.mimeType, - fileName: attachment.fileName, + fileName: this.readDecryptedString(attachment.fileName), fileSize: attachment.fileSize, width: attachment.width, height: attachment.height, durationMs: attachment.durationMs, - checksum: attachment.checksum, + checksum: this.readDecryptedString(attachment.checksum), createdAt: attachment.createdAt.toISOString(), updatedAt: attachment.updatedAt.toISOString() }; @@ -204,10 +209,9 @@ export class AttachmentService { return Math.min(configValue, 604800); } - private generateObjectKey(userId: string, fileName: string): string { - const safeFileName = fileName.replace(/[^\w.-]+/g, "_"); + private generateObjectKey(fileName: string): string { const datePrefix = new Date().toISOString().slice(0, 10); - return `${userId}/${datePrefix}/${randomUUID()}-${safeFileName}`; + return `attachments/${datePrefix}/${randomUUID()}${this.extractFileExtension(fileName)}`; } private resolveObjectUrl(bucket: string, objectKey: string): string { @@ -232,6 +236,37 @@ export class AttachmentService { return AttachmentType.FILE; } + private buildUploadHeaders( + mimeType: string, + serverSideEncryption: "AES256" | undefined + ): Record { + const headers: Record = { + "Content-Type": mimeType + }; + + if (serverSideEncryption) { + headers["x-amz-server-side-encryption"] = serverSideEncryption; + } + + return headers; + } + + private getServerSideEncryptionMode(): "AES256" | undefined { + const configValue = + this.configService.get("S3_SERVER_SIDE_ENCRYPTION")?.trim().toUpperCase() ?? "AES256"; + + if (configValue === "NONE" || configValue === "DISABLED") { + return undefined; + } + + return "AES256"; + } + + private extractFileExtension(fileName: string): string { + const match = /\.[a-zA-Z0-9]{1,16}$/.exec(fileName); + return match?.[0]?.toLowerCase() ?? ""; + } + private async ensureTaskOwnership(userId: string, taskId: string): Promise { const task = await this.prismaService.task.findFirst({ where: { @@ -279,4 +314,22 @@ export class AttachmentService { throw new PayloadTooLargeException("存储配额不足"); } } + + private encryptRequiredString(value: string): string { + const encryptedValue = this.dataEncryptionService.encryptString(value); + if (!encryptedValue) { + throw new InternalServerErrorException("附件元数据加密失败"); + } + + return encryptedValue; + } + + private encryptNullableString(value: string | null | undefined): string | null | undefined { + return this.dataEncryptionService.encryptString(value); + } + + private readDecryptedString(value: string | null): string | null { + const decryptedValue = this.dataEncryptionService.decryptString(value); + return typeof decryptedValue === "string" ? decryptedValue : null; + } } diff --git a/apps/api/src/security/data-encryption.service.ts b/apps/api/src/security/data-encryption.service.ts new file mode 100644 index 0000000..2af16fd --- /dev/null +++ b/apps/api/src/security/data-encryption.service.ts @@ -0,0 +1,139 @@ +import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Prisma } from "../../generated/prisma/client"; +import { createCipheriv, createDecipheriv, createHash, 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); + } + + 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(); + } +} diff --git a/apps/api/src/security/security.module.ts b/apps/api/src/security/security.module.ts new file mode 100644 index 0000000..8373141 --- /dev/null +++ b/apps/api/src/security/security.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { DataEncryptionService } from "./data-encryption.service"; + +@Global() +@Module({ + providers: [DataEncryptionService], + exports: [DataEncryptionService] +}) +export class SecurityModule {} diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 9bab5e2..cfd0f49 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { Prisma } from "../../generated/prisma/client"; import { PrismaService } from "../prisma/prisma.service"; +import { DataEncryptionService } from "../security/data-encryption.service"; import { SyncPullQueryDto } from "./dto/sync-pull.dto"; import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto"; @@ -60,7 +61,10 @@ export type SyncPullResponse = { @Injectable() export class SyncService { - constructor(private readonly prismaService: PrismaService) {} + constructor( + private readonly prismaService: PrismaService, + private readonly dataEncryptionService: DataEncryptionService + ) {} async pullOperations(userId: string, query: SyncPullQueryDto): Promise { const limit = query.limit ?? 100; @@ -137,7 +141,7 @@ export class SyncService { entityType: operation.entityType, entityId: operation.entityId, action: operation.action, - payload: operation.payload, + payload: this.dataEncryptionService.encryptString(operation.payload) ?? undefined, clientTs: new Date(operation.clientTs) }, select: { @@ -252,15 +256,7 @@ export class SyncService { } private serializePayload(payload: Prisma.JsonValue | null): string | null { - if (payload === null) { - return null; - } - - if (typeof payload === "string") { - return payload; - } - - return JSON.stringify(payload); + return this.dataEncryptionService.decryptPayload(payload); } private parseCursor(cursor: string | undefined): SyncPullCursorState | null { diff --git a/apps/api/src/task/task.service.ts b/apps/api/src/task/task.service.ts index f754003..deb1f76 100644 --- a/apps/api/src/task/task.service.ts +++ b/apps/api/src/task/task.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client"; import { PrismaService } from "../prisma/prisma.service"; +import { DataEncryptionService } from "../security/data-encryption.service"; import { CreateTaskDto } from "./dto/create-task.dto"; import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto"; import { UpdateTaskDto } from "./dto/update-task.dto"; @@ -43,16 +44,48 @@ export type ListTasksResponse = { @Injectable() export class TaskService { - constructor(private readonly prismaService: PrismaService) {} + constructor( + private readonly prismaService: PrismaService, + private readonly dataEncryptionService: DataEncryptionService + ) {} async listTasks(userId: string, query: ListTasksQueryDto): Promise { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const skip = (page - 1) * pageSize; + const keyword = query.keyword?.trim() ?? ""; - const where = this.buildWhereInput(userId, query); + const where = this.buildWhereInput(userId, query, keyword.length === 0); const orderBy = this.buildOrderByInput(query); + if (keyword.length > 0) { + const items = await this.prismaService.task.findMany({ + where, + orderBy, + include: { + taskTags: { + include: { + tag: { + select: { + name: true + } + } + } + } + } + }); + + const serializedItems = items.map((item: TaskEntity) => this.serializeTask(item)); + const filteredItems = serializedItems.filter((item) => this.matchesKeyword(item, keyword)); + + return { + items: filteredItems.slice(skip, skip + pageSize), + page, + pageSize, + total: filteredItems.length + }; + } + const [items, total] = await Promise.all([ this.prismaService.task.findMany({ where, @@ -112,15 +145,18 @@ export class TaskService { const tagNames = this.normalizeTagNames(body.tagNames); const nextStatus = body.status ?? TaskStatus.TODO; const contentJson = - body.contentJson !== undefined ? (body.contentJson as Prisma.InputJsonValue) : undefined; + body.contentJson !== undefined + ? ((this.dataEncryptionService.encryptJson(body.contentJson as Prisma.InputJsonValue) ?? + Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput) + : undefined; const task = await this.prismaService.$transaction(async (tx) => { const createdTask = await tx.task.create({ data: { userId, - title: body.title, + title: this.encryptRequiredString(body.title), contentJson, - contentText: body.contentText ?? null, + contentText: this.encryptNullableString(body.contentText), priority: body.priority ?? TaskPriority.MEDIUM, status: nextStatus, ddl: body.ddl ? new Date(body.ddl) : null, @@ -172,13 +208,15 @@ export class TaskService { }; if (body.title !== undefined) { - data.title = body.title; + data.title = this.encryptRequiredString(body.title); } if (body.contentJson !== undefined) { - data.contentJson = body.contentJson as Prisma.InputJsonValue; + data.contentJson = (this.dataEncryptionService.encryptJson( + body.contentJson as Prisma.InputJsonValue + ) ?? Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput; } if (body.contentText !== undefined) { - data.contentText = body.contentText; + data.contentText = this.encryptNullableString(body.contentText); } if (body.priority !== undefined) { data.priority = body.priority; @@ -242,7 +280,11 @@ export class TaskService { return { success: true }; } - private buildWhereInput(userId: string, query: ListTasksQueryDto): Prisma.TaskWhereInput { + private buildWhereInput( + userId: string, + query: ListTasksQueryDto, + includeKeyword: boolean + ): Prisma.TaskWhereInput { const where: Prisma.TaskWhereInput = { userId }; @@ -267,7 +309,7 @@ export class TaskService { }; } - if (query.keyword !== undefined && query.keyword.length > 0) { + if (includeKeyword && query.keyword !== undefined && query.keyword.length > 0) { where.OR = [ { title: { @@ -374,9 +416,9 @@ export class TaskService { private serializeTask(task: TaskEntity): TaskResponse { return { id: task.id, - title: task.title, - contentJson: task.contentJson, - contentText: task.contentText, + title: this.readDecryptedString(task.title) ?? "未命名任务", + contentJson: this.dataEncryptionService.decryptJson(task.contentJson), + contentText: this.readDecryptedString(task.contentText), priority: task.priority, status: task.status, ddl: task.ddl?.toISOString() ?? null, @@ -387,4 +429,30 @@ export class TaskService { updatedAt: task.updatedAt.toISOString() }; } + + private encryptRequiredString(value: string): string { + const encryptedValue = this.dataEncryptionService.encryptString(value); + if (!encryptedValue) { + throw new InternalServerErrorException("任务字段加密失败"); + } + + return encryptedValue; + } + + private encryptNullableString(value: string | null | undefined): string | null | undefined { + return this.dataEncryptionService.encryptString(value); + } + + private readDecryptedString(value: string | null): string | null { + const decryptedValue = this.dataEncryptionService.decryptString(value); + return typeof decryptedValue === "string" ? decryptedValue : null; + } + + private matchesKeyword(task: TaskResponse, keyword: string): boolean { + const lowerKeyword = keyword.toLocaleLowerCase(); + return ( + task.title.toLocaleLowerCase().includes(lowerKeyword) || + task.contentText?.toLocaleLowerCase().includes(lowerKeyword) === true + ); + } } diff --git a/apps/api/test/ai.spec.ts b/apps/api/test/ai.spec.ts index 6bc9cce..d387db5 100644 --- a/apps/api/test/ai.spec.ts +++ b/apps/api/test/ai.spec.ts @@ -1,5 +1,6 @@ import request from "supertest"; import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { AiChannel, @@ -19,6 +20,7 @@ import { AiRouteFailureError } from "../src/ai/ai.types"; import { PrismaService } from "../src/prisma/prisma.service"; +import { DataEncryptionService } from "../src/security/data-encryption.service"; type AiUsageLogRecord = { id: string; @@ -297,6 +299,10 @@ class InMemoryAiPrismaService { return [...this.usageLogs]; } + getBindings(): AiProviderBinding[] { + return [...this.bindings]; + } + seedTask(task: AiTaskRecord): void { this.tasks.push(task); } @@ -401,10 +407,18 @@ describe("AiController (integration)", () => { controllers: [AiController], providers: [ AiService, + DataEncryptionService, { provide: PrismaService, useValue: prismaService }, + { + provide: ConfigService, + useValue: { + get: (key: string) => + key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined + } + }, { provide: AiProviderRegistryService, useValue: { @@ -466,6 +480,11 @@ describe("AiController (integration)", () => { maskedApiKey: "abk_***34", isEnabled: true }); + + const storedBinding = prismaService.getBindings()[0]; + expect(storedBinding?.providerName).not.toBe("astrbot-main"); + expect(storedBinding?.endpoint).not.toBe("http://127.0.0.1:6185"); + expect(storedBinding?.encryptedApiKey).not.toBe("abk_secret_1234"); }); it("should hide public pool endpoint from user bindings response", async () => { diff --git a/apps/api/test/sync-push.spec.ts b/apps/api/test/sync-push.spec.ts index dfbacba..3c75f9b 100644 --- a/apps/api/test/sync-push.spec.ts +++ b/apps/api/test/sync-push.spec.ts @@ -1,7 +1,9 @@ import request from "supertest"; import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { PrismaService } from "../src/prisma/prisma.service"; +import { DataEncryptionService } from "../src/security/data-encryption.service"; import { SyncController } from "../src/sync/sync.controller"; import { SyncService } from "../src/sync/sync.service"; @@ -159,6 +161,10 @@ class InMemoryPrismaService { return this.syncOperations.length; } + getRawOperationById(opId: string): SyncOperationRecord | undefined { + return this.syncOperations.find((operation) => operation.opId === opId); + } + seedOperations(records: Array>): void { for (const record of records) { this.syncOperations.push({ @@ -196,7 +202,18 @@ describe("SyncController (integration)", () => { const moduleRef: TestingModule = await Test.createTestingModule({ controllers: [SyncController], - providers: [SyncService, { provide: PrismaService, useValue: prismaService }] + providers: [ + SyncService, + DataEncryptionService, + { provide: PrismaService, useValue: prismaService }, + { + provide: ConfigService, + useValue: { + get: (key: string) => + key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined + } + } + ] }).compile(); app = moduleRef.createNestApplication(); @@ -258,6 +275,9 @@ describe("SyncController (integration)", () => { }) ]); expect(prismaService.getOperationCount()).toBe(2); + expect(prismaService.getRawOperationById("op-create-1")?.payload).not.toBe( + '{"title":"浠诲姟涓€"}' + ); const secondResponse = await request(app.getHttpServer()) .post("/sync/push") diff --git a/apps/api/test/task.spec.ts b/apps/api/test/task.spec.ts index fcc6799..98b25cd 100644 --- a/apps/api/test/task.spec.ts +++ b/apps/api/test/task.spec.ts @@ -1,7 +1,9 @@ import request from "supertest"; import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import { PrismaService } from "../src/prisma/prisma.service"; +import { DataEncryptionService } from "../src/security/data-encryption.service"; import { TaskController } from "../src/task/task.controller"; import { TaskService } from "../src/task/task.service"; import { TaskPriority, TaskStatus } from "../generated/prisma/client"; @@ -355,6 +357,10 @@ class InMemoryPrismaService { return runner(this); } + getRawTaskById(taskId: string): TaskRecord | undefined { + return this.tasks.find((task) => task.id === taskId); + } + private toTaskWithTags( task: TaskRecord ): TaskRecord & { taskTags: Array<{ tag: { name: string } }> } { @@ -390,7 +396,15 @@ describe("TaskController (integration)", () => { controllers: [TaskController], providers: [ TaskService, - { provide: PrismaService, useValue: prismaService as unknown as PrismaService } + DataEncryptionService, + { provide: PrismaService, useValue: prismaService as unknown as PrismaService }, + { + provide: ConfigService, + useValue: { + get: (key: string) => + key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined + } + } ] }).compile(); @@ -425,6 +439,9 @@ describe("TaskController (integration)", () => { expect(createResponse.body.id).toBeDefined(); expect(createResponse.body.tags).toEqual(["工作", "会议"]); const taskId = createResponse.body.id as string; + const rawCreatedTask = prismaService.getRawTaskById(taskId); + expect(rawCreatedTask?.title).not.toBe("准备周会"); + expect(rawCreatedTask?.contentText).not.toBe("整理本周进度"); const listResponse = await request(app.getHttpServer()) .get("/tasks")