feat: implement frontend-backend encrypted communication via AES-256-GCM

参考 novavis-authority 的加解密方案,实现前后端通信的应用层加密:
- 重写 src/lib/crypto.ts 使用 Web Crypto API(浏览器兼容),PBKDF2+AES-256-GCM
- 新增 src/lib/crypto-server.ts 服务端加解密工具(Node.js crypto)
- 新增 src/lib/api-crypto.ts API 路由中间件 withCrypto(),自动解密请求体/加密响应体
- 更新 src/lib/admin-api.ts 自动加密所有请求/解密响应
- 所有 11 个 admin API 路由文件已应用 withCrypto 包装器
- 更新 .env 文件,添加 NEXT_PUBLIC_ENCRYPTION_SECRET 和 ENCRYPTION_SECRET
This commit is contained in:
2026-08-02 09:11:36 +08:00
parent c480772aec
commit d5d04aa96d
24 changed files with 449 additions and 120 deletions
+7
View File
@@ -20,3 +20,10 @@ CMS_REVALIDATE_SECRET=change-me-in-production-min-32-bytes
# CDN 配置(可选)
CDN_DOMAIN=
# 前后端通信加密密钥(生产环境必须替换为高强度随机字符串,不得低于 12 字符)
# 前端: NEXT_PUBLIC_ENCRYPTION_SECRET 编译时注入,后端: ENCRYPTION_SECRET 运行时环境变量
# 前后端值必须保持一致,否则加解密会失败
# 可使用 openssl rand -base64 32 生成
NEXT_PUBLIC_ENCRYPTION_SECRET=change-me-in-production-min-12-chars
ENCRYPTION_SECRET=change-me-in-production-min-12-chars
@@ -42,6 +42,8 @@ import { POST } from './route';
function createMockRequest(body: Record<string, unknown>): NextRequest {
return {
headers: new Headers(),
url: 'http://localhost:3000/api/admin/items/item-1/workflow',
json: async () => body,
} as unknown as NextRequest;
}
@@ -15,6 +15,7 @@ import {
internalError,
forbidden,
} from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) };
@@ -32,10 +33,10 @@ const ACTION_PERMISSION: Record<WorkflowAction, 'update' | 'publish'> = {
archive: 'publish',
};
export async function POST(
export const POST = withCrypto(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
) => {
const { id } = await params;
try {
@@ -90,4 +91,4 @@ export async function POST(
console.error('Workflow error:', error);
return internalError();
}
}
});
+1
View File
@@ -46,6 +46,7 @@ function createMockRequest(options: {
json?: () => Promise<Record<string, unknown>>;
}): NextRequest {
return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/items',
json: options.json || (async () => ({})),
} as unknown as NextRequest;
+9 -8
View File
@@ -7,6 +7,7 @@ import {
validationError,
internalError,
} from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) };
@@ -46,7 +47,7 @@ async function ensureUniqueSlug(
}
// GET /api/admin/items - 获取内容列表
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const modelCode = searchParams.get('modelCode');
@@ -91,10 +92,10 @@ export async function GET(request: NextRequest) {
console.error('Get items error:', error);
return internalError();
}
}
});
// POST /api/admin/items - 创建内容
export async function POST(request: NextRequest) {
export const POST = withCrypto(async (request: NextRequest) => {
try {
const body = await request.json();
const { modelId, modelCode, title, slug, data, status, sortOrder } = body;
@@ -151,10 +152,10 @@ export async function POST(request: NextRequest) {
console.error('Create item error:', error);
return internalError();
}
}
});
// PUT /api/admin/items/[id] - 更新内容
export async function PUT(request: NextRequest) {
export const PUT = withCrypto(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
@@ -218,10 +219,10 @@ export async function PUT(request: NextRequest) {
console.error('Update item error:', error);
return internalError();
}
}
});
// DELETE /api/admin/items/[id] - 删除内容
export async function DELETE(request: NextRequest) {
export const DELETE = withCrypto(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
@@ -250,4 +251,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete item error:', error);
return internalError();
}
}
});
+1
View File
@@ -28,6 +28,7 @@ function createMockRequest(options: {
formData?: () => Promise<FormData>;
}): NextRequest {
return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/media',
formData: options.formData || (async () => new FormData()),
} as unknown as NextRequest;
+7 -6
View File
@@ -12,12 +12,13 @@ import {
validationError,
internalError,
} from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
const MODEL_CODE = 'media';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// GET /api/admin/media - 获取媒体列表或按 ID 查询
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, MODEL_CODE, 'read');
if ('response' in permission) return permission.response;
@@ -41,10 +42,10 @@ export async function GET(request: NextRequest) {
console.error('Get media error:', error);
return internalError();
}
}
});
// POST /api/admin/media - 上传媒体文件(支持单文件/多文件)
export async function POST(request: NextRequest) {
export const POST = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, MODEL_CODE, 'create');
if ('response' in permission) return permission.response;
@@ -94,10 +95,10 @@ export async function POST(request: NextRequest) {
console.error('Upload media error:', error);
return internalError();
}
}
});
// DELETE /api/admin/media?id=xxx - 删除媒体文件
export async function DELETE(request: NextRequest) {
export const DELETE = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, MODEL_CODE, 'delete');
if ('response' in permission) return permission.response;
@@ -115,4 +116,4 @@ export async function DELETE(request: NextRequest) {
}
return internalError();
}
}
});
+1
View File
@@ -27,6 +27,7 @@ import { GET } from './route';
function createMockRequest(): NextRequest {
return {
headers: new Headers(),
url: 'http://localhost/api/admin/models',
} as unknown as NextRequest;
}
+3 -2
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { requirePermission } from '@/lib/permissions';
import { success, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/models - 获取所有内容模型
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, 'content-model', 'read');
if ('response' in permission) return permission.response;
@@ -21,4 +22,4 @@ export async function GET(request: NextRequest) {
console.error('Get models error:', error);
return internalError();
}
}
});
@@ -20,7 +20,7 @@ jest.unmock('./route');
import { GET } from './route';
function createMockRequest(url: string): NextRequest {
return { url } as unknown as NextRequest;
return { headers: new Headers(), url } as unknown as NextRequest;
}
beforeEach(() => {
+3 -2
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { getUserNotifications } from '@/lib/cms/notifications';
import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/notifications - 获取当前用户通知列表
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -27,4 +28,4 @@ export async function GET(request: NextRequest) {
console.error('Get notifications error:', error);
return internalError();
}
}
});
@@ -20,7 +20,7 @@ jest.unmock('./route');
import { GET } from './route';
function createMockRequest(): NextRequest {
return {} as unknown as NextRequest;
return { headers: new Headers() } as unknown as NextRequest;
}
beforeEach(() => {
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { getUnreadCount } from '@/lib/cms/notifications';
import { success, unauthorized, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -15,4 +16,4 @@ export async function GET(request: NextRequest) {
console.error('Get unread count error:', error);
return internalError();
}
}
});
+1
View File
@@ -43,6 +43,7 @@ import { GET, PUT } from './route';
function createMockRequest(body?: Record<string, unknown>): NextRequest {
return {
headers: new Headers(),
json: async () => body ?? {},
} as unknown as NextRequest;
}
+5 -4
View File
@@ -2,12 +2,13 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// 内置角色,不允许删除
const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor', 'reviewer', 'readonly']);
// GET /api/admin/roles - 获取角色列表及其权限
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -46,10 +47,10 @@ export async function GET(request: NextRequest) {
console.error('Get roles error:', error);
return internalError();
}
}
});
// PUT /api/admin/roles/:roleCode - 更新角色权限
export async function PUT(request: NextRequest) {
export const PUT = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -100,4 +101,4 @@ export async function PUT(request: NextRequest) {
console.error('Update roles error:', error);
return internalError();
}
}
});
+3 -2
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/stats - 获取仪表盘统计数据
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -79,4 +80,4 @@ export async function GET(request: NextRequest) {
console.error('Get stats error:', error);
return internalError();
}
}
});
+9 -8
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest, hashPassword } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/users - 获取用户列表
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -77,10 +78,10 @@ export async function GET(request: NextRequest) {
console.error('Get users error:', error);
return internalError();
}
}
});
// POST /api/admin/users - 创建用户
export async function POST(request: NextRequest) {
export const POST = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
if (!user) return unauthorized();
@@ -153,10 +154,10 @@ export async function POST(request: NextRequest) {
console.error('Create user error:', error);
return internalError();
}
}
});
// PUT /api/admin/users - 更新用户
export async function PUT(request: NextRequest) {
export const PUT = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request);
if (!currentUser) return unauthorized();
@@ -220,10 +221,10 @@ export async function PUT(request: NextRequest) {
console.error('Update user error:', error);
return internalError();
}
}
});
// DELETE /api/admin/users - 删除用户
export async function DELETE(request: NextRequest) {
export const DELETE = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request);
if (!currentUser) return unauthorized();
@@ -260,4 +261,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete user error:', error);
return internalError();
}
}
});
+1
View File
@@ -42,6 +42,7 @@ function createMockRequest(options: {
json?: () => Promise<Record<string, unknown>>;
}): NextRequest {
return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/zones',
json: options.json || (async () => ({})),
} as unknown as NextRequest;
+9 -8
View File
@@ -7,9 +7,10 @@ import {
validationError,
internalError,
} from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/zones - 获取所有内容区域
export async function GET(request: NextRequest) {
export const GET = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, 'content-zone', 'read');
if ('response' in permission) return permission.response;
@@ -30,10 +31,10 @@ export async function GET(request: NextRequest) {
console.error('Get zones error:', error);
return internalError();
}
}
});
// POST /api/admin/zones - 创建/更新内容区域
export async function POST(request: NextRequest) {
export const POST = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, 'content-zone', 'update');
if ('response' in permission) return permission.response;
@@ -95,15 +96,15 @@ export async function POST(request: NextRequest) {
console.error('Save zone error:', error);
return internalError();
}
}
});
// PUT /api/admin/zones - 更新区域设置
export async function PUT(request: NextRequest) {
export const PUT = withCrypto(async (request: NextRequest) => {
return POST(request);
}
});
// DELETE /api/admin/zones/[id] - 删除内容区域
export async function DELETE(request: NextRequest) {
export const DELETE = withCrypto(async (request: NextRequest) => {
const permission = await requirePermission(request, 'content-zone', 'delete');
if ('response' in permission) return permission.response;
@@ -132,4 +133,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete zone error:', error);
return internalError();
}
}
});
+40 -17
View File
@@ -8,13 +8,19 @@ class AdminApiClient {
return localStorage.getItem('novalon_admin_token');
}
/**
* 检查前端加密是否可用(NEXT_PUBLIC_ENCRYPTION_SECRET 已配置)
*/
private isEncryptionAvailable(): boolean {
return !!process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
}
async request<T>(
path: string,
options: RequestInit & { encrypt?: boolean } = {},
options: RequestInit = {},
): Promise<T> {
const token = this.getToken();
// 当前服务端 API 未实现请求体解密,默认不加密;如需加密,调用方显式传入 encrypt: true。
const shouldEncrypt = options.encrypt === true && token;
const shouldEncrypt = this.isEncryptionAvailable();
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
@@ -26,19 +32,24 @@ class AdminApiClient {
let body = options.body;
// 自动加密请求体(有 body 且加密可用时)
if (shouldEncrypt && body && token) {
try {
const parsed = JSON.parse(body as string);
const encrypted = encrypt(parsed, token);
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
const jsonStr = typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
const encrypted = await encrypt(jsonStr);
headers['Content-Type'] = 'application/json';
headers['X-Encrypted'] = '1';
headers['X-Encrypted'] = 'true';
body = JSON.stringify({ data: encrypted });
} catch {
// 如果不是 JSON 或加密失败,使用原始 body
// 加密失败时降级为明文传输
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
}
} else if (shouldEncrypt && token) {
// 无 body 的请求(GET/DELETE)也要标记加密,让后端加密响应
headers['X-Encrypted'] = 'true';
} else if (body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
@@ -58,22 +69,34 @@ class AdminApiClient {
throw new Error('未授权');
}
// 解密响应体
const isEncryptedResponse = res.headers.get('X-Encrypted') === 'true';
if (isEncryptedResponse) {
try {
const responseText = await res.text();
const responseJson = JSON.parse(responseText);
const encryptedData = responseJson.data as string;
if (encryptedData) {
const decryptedText = await decrypt(encryptedData);
return JSON.parse(decryptedText) as T;
}
return responseJson as T;
} catch (e) {
// 解密失败时尝试读取原始 JSON
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || data.message || `请求失败 (${res.status})`);
}
throw e;
}
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || data.message || `请求失败 (${res.status})`);
}
const data = await res.json();
// 解密响应
if (shouldEncrypt && token && data.encrypted) {
try {
return decrypt<T>(data.encrypted, token);
} catch {
return data as T;
}
}
return data as T;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* API 路由加解密中间件
*
* 参考 novavis-authority 的 CryptoFilter,在 Next.js API Route 中实现:
* - 请求体:当 X-Encrypted: true 时,自动解密请求体 JSON
* - 响应体:当请求携带 X-Encrypted: true 时,自动加密响应体
*
* 用法:
* ```typescript
* import { withCrypto } from '@/lib/api-crypto';
*
* export const POST = withCrypto(async (request) => {
* const body = await request.json(); // 已自动解密
* return Response.json({ data: '敏感数据' });
* });
* ```
*/
import { NextRequest, NextResponse } from 'next/server';
import { encrypt, decrypt, isEncryptionAvailable } from '@/lib/crypto-server';
const ENCRYPTED_HEADER = 'x-encrypted';
interface CryptoContext {
/** 原始请求是否携带了 X-Encrypted 头 */
isEncrypted: boolean;
}
/**
* 包装 API Route handler,自动处理请求体解密和响应体加密
*/
export function withCrypto<T extends Record<string, unknown> = Record<string, unknown>>(
handler: (
request: NextRequest,
context: CryptoContext & T,
) => Promise<NextResponse>,
): (request: NextRequest, routeContext?: T) => Promise<NextResponse> {
return async (request: NextRequest, routeContext?: T) => {
const ctx = (routeContext ?? {}) as T;
const isEncrypted =
request.headers.get(ENCRYPTED_HEADER)?.toLowerCase() === 'true';
if (!isEncrypted || !isEncryptionAvailable()) {
// 未加密请求直接透传
return handler(request, { ...ctx, isEncrypted: false } as CryptoContext & T);
}
// ---- 解密请求体 ----
const contentLength = request.headers.get('content-length');
const hasBody = contentLength && parseInt(contentLength) > 0;
if (hasBody) {
try {
const cloned = request.clone();
const bodyText = await cloned.text();
if (bodyText) {
const bodyJson = JSON.parse(bodyText);
const encryptedData = bodyJson.data as string | undefined;
if (encryptedData) {
const decryptedText = decrypt(encryptedData);
// 重建 request,将解密后的 JSON 注入 body
const newHeaders = new Headers(request.headers);
newHeaders.set('content-type', 'application/json');
newHeaders.delete('content-length'); // 让框架自动计算
const newRequest = new NextRequest(request.url, {
method: request.method,
headers: newHeaders,
body: decryptedText,
});
// 传递解密后的请求给 handler,并加密响应
const response = await handler(newRequest, { ...ctx, isEncrypted: true } as CryptoContext & T);
return encryptResponse(response);
}
}
} catch (e) {
console.error('[Crypto] Request body decrypt failed:', e);
return NextResponse.json(
{
error: '请求体解密失败',
message: e instanceof Error ? e.message : 'unknown error',
},
{ status: 400 },
);
}
}
// 无 body 的请求(GET/DELETE)仍需要加密响应
const response = await handler(request, { ...ctx, isEncrypted: true } as CryptoContext & T);
return encryptResponse(response);
};
}
/**
* 加密响应体
*/
async function encryptResponse(
response: NextResponse,
): Promise<NextResponse> {
// 跳过非 JSON 响应(如流、文件下载)
const contentType = response.headers.get('content-type');
if (contentType && !contentType.includes('application/json')) {
return response;
}
try {
const cloned = response.clone();
const bodyText = await cloned.text();
if (!bodyText) return response;
const encrypted = encrypt(bodyText);
// 用加密后的响应替换原响应
const newHeaders = new Headers(response.headers);
newHeaders.set('content-type', 'application/json');
newHeaders.set(ENCRYPTED_HEADER, 'true');
return new NextResponse(JSON.stringify({ data: encrypted }), {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
} catch (e) {
console.error('[Crypto] Response encrypt failed:', e);
return response;
}
}
// ========== 低阶工具函数(供不使用 withCrypto 的 API Route 直接调用)==========
/**
* 从请求中提取并解密 body 数据
*/
export async function decryptRequest<T = unknown>(
request: NextRequest,
): Promise<T | null> {
if (!isEncryptionAvailable()) return null;
const isEncrypted =
request.headers.get(ENCRYPTED_HEADER)?.toLowerCase() === 'true';
if (!isEncrypted) return null;
try {
const body = await request.clone().json();
const encryptedData = body.data as string;
if (!encryptedData) return null;
const decrypted = decrypt(encryptedData);
return JSON.parse(decrypted) as T;
} catch (e) {
console.error('[Crypto] decryptRequest failed:', e);
return null;
}
}
/**
* 加密响应数据,设置 X-Encrypted 头
*/
export function encryptResponseData(data: unknown): NextResponse {
const encrypted = encrypt(JSON.stringify(data));
return NextResponse.json(
{ data: encrypted },
{ headers: { [ENCRYPTED_HEADER]: 'true' } },
);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 服务端 AES-256-GCM 加解密工具(Node.js 环境)
*
* 使用 PBKDF2-HMAC-SHA256 从 ENCRYPTION_SECRET 派生密钥,
* 与 crypto.ts 共享相同的算法和参数,实现前后端加解密互通。
*
* 供 Next.js API Route 在服务端解密请求体、加密响应体。
*/
import crypto from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_SALT = 'novalon-website-crypto-salt-v1';
const KEY_LENGTH = 32; // 256 bits
function getSecret(): string {
const secret = process.env.ENCRYPTION_SECRET;
if (!secret) {
throw new Error('ENCRYPTION_SECRET 未配置,服务端加解密无法初始化');
}
return secret;
}
let cachedKey: Buffer | null = null;
function deriveKey(): Buffer {
if (cachedKey) return cachedKey;
const secret = getSecret();
cachedKey = crypto.pbkdf2Sync(
secret,
PBKDF2_SALT,
PBKDF2_ITERATIONS,
KEY_LENGTH,
'sha256',
);
return cachedKey;
}
/**
* 加密明文,返回 Base64 编码的密文(12-byte-IV || AES-GCM-ciphertext
*/
export function encrypt(plaintext: string): string {
const key = deriveKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// 格式: iv + authTag + encrypted
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* 解密 Base64 编码的密文,返回明文字符串
*/
export function decrypt(encryptedBase64: string): string {
const key = deriveKey();
const combined = Buffer.from(encryptedBase64, 'base64');
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString('utf8');
}
/**
* 验证加密配置是否可用
*/
export function isEncryptionAvailable(): boolean {
return !!process.env.ENCRYPTION_SECRET;
}
+77 -53
View File
@@ -1,69 +1,93 @@
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const SALT = 'novalon-cms-crypto-salt';
/**
* 从 JWT token 派生加密密钥
* 前后端通信 AES-256-GCM 加解密工具(前端浏览器环境)
*
* 使用 PBKDF2-HMAC-SHA256 从 NEXT_PUBLIC_ENCRYPTION_SECRET 派生密钥,
* 与 crypto-server.ts 共享相同的算法和参数,实现前后端加解密互通。
*
* 参考同级项目 novavis-authority 的加解密方案。
*
* 输出格式: Base64(12-byte-IV || AES-GCM-ciphertext)
*/
function deriveKey(token: string): Buffer {
return crypto.pbkdf2Sync(token, SALT, 10000, 32, 'sha256');
const IV_LENGTH = 12;
const ALGORITHM = 'AES-GCM';
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_SALT = new TextEncoder().encode('novalon-website-crypto-salt-v1');
async function deriveKey(passphrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveBits', 'deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
keyMaterial,
{ name: ALGORITHM, length: 256 },
false,
['encrypt', 'decrypt'],
);
}
function getSecret(): string {
const secret = process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
if (!secret) {
throw new Error('NEXT_PUBLIC_ENCRYPTION_SECRET 未配置,加解密无法初始化');
}
return secret;
}
let cachedKey: Promise<CryptoKey> | null = null;
function getKey(): Promise<CryptoKey> {
if (!cachedKey) {
cachedKey = deriveKey(getSecret());
}
return cachedKey;
}
/**
* 加密数据
* 加密明文,返回 Base64 编码的密文(IV 前置)
*/
export function encrypt(data: unknown, token: string): string {
const key = deriveKey(token);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
export async function encrypt(plaintext: string): Promise<string> {
const key = await getKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoder = new TextEncoder();
const ciphertext = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv, tagLength: 128 },
key,
encoder.encode(plaintext),
);
const json = JSON.stringify(data);
const encrypted = Buffer.concat([
cipher.update(json, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), IV_LENGTH);
// 格式: iv + authTag + encrypted (全部 base64)
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join(''));
}
/**
* 解密数据
* 解密 Base64 编码的密文(IV 前置),返回明文字符串
*/
export function decrypt<T = unknown>(encryptedData: string, token: string): T {
const key = deriveKey(token);
const combined = Buffer.from(encryptedData, 'base64');
export async function decrypt(encryptedBase64: string): Promise<string> {
const key = await getKey();
const binaryStr = atob(encryptedBase64);
const combined = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
combined[i] = binaryStr.codePointAt(i)!;
}
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const iv = combined.slice(0, IV_LENGTH);
const ciphertext = combined.slice(IV_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv, tagLength: 128 },
key,
ciphertext,
);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return JSON.parse(decrypted.toString('utf8')) as T;
}
/**
* 生成随机 Token(用于前端存储的加密密钥)
*/
export function generateCryptoToken(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* 对敏感字段进行哈希(用于日志脱敏)
*/
export function hashSensitive(data: string): string {
return crypto.createHash('sha256').update(data + SALT).digest('hex').slice(0, 16);
return new TextDecoder().decode(decrypted);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest, type JwtPayload } from '@/lib/auth';
import { unauthorized, forbidden } from '@/lib/api-response';
@@ -61,7 +61,7 @@ export interface PermissionSuccess {
}
export interface PermissionFailure {
response: Response;
response: NextResponse;
}
/**