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:
+78
-54
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user