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
+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;
}
+78 -54
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);
const json = JSON.stringify(data);
const encrypted = Buffer.concat([
cipher.update(json, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
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),
);
// 格式: iv + authTag + encrypted (全部 base64)
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), IV_LENGTH);
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;
}
/**