Files
novalon-website/src/lib/crypto.ts
T
zhangxiang d5d04aa96d 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
2026-08-02 09:11:36 +08:00

93 lines
2.7 KiB
TypeScript

/**
* 前后端通信 AES-256-GCM 加解密工具(前端浏览器环境)
*
* 使用 PBKDF2-HMAC-SHA256 从 NEXT_PUBLIC_ENCRYPTION_SECRET 派生密钥,
* 与 crypto-server.ts 共享相同的算法和参数,实现前后端加解密互通。
*
* 参考同级项目 novavis-authority 的加解密方案。
*
* 输出格式: Base64(12-byte-IV || AES-GCM-ciphertext)
*/
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 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 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 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.slice(0, IV_LENGTH);
const ciphertext = combined.slice(IV_LENGTH);
const decrypted = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv, tagLength: 128 },
key,
ciphertext,
);
return new TextDecoder().decode(decrypted);
}