feat(api-security): encrypt sensitive data at rest

This commit is contained in:
2026-04-06 15:25:10 +08:00
parent 13d0d7707a
commit 13abfc1e52
13 changed files with 739 additions and 80 deletions
+8
View File
@@ -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"
+2 -1
View File
@@ -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",
@@ -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<void> {
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;
});
+63 -33
View File
@@ -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<ListAiBindingsResponse> {
@@ -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);
+2
View File
@@ -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,
+72 -19
View File
@@ -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<string, string>;
};
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<string, string> {
const headers: Record<string, string> = {
"Content-Type": mimeType
};
if (serverSideEncryption) {
headers["x-amz-server-side-encryption"] = serverSideEncryption;
}
return headers;
}
private getServerSideEncryptionMode(): "AES256" | undefined {
const configValue =
this.configService.get<string>("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<void> {
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;
}
}
@@ -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<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);
}
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();
}
}
+9
View File
@@ -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 {}
+7 -11
View File
@@ -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<SyncPullResponse> {
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 {
+82 -14
View File
@@ -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<ListTasksResponse> {
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
);
}
}
+19
View File
@@ -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 () => {
+21 -1
View File
@@ -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<Omit<SyncOperationRecord, "id">>): 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")
+18 -1
View File
@@ -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")