- 在editStorageForm中初始化oss_storage_quota_value和oss_quota_unit - 删除重复的旧配额说明块,保留新的当前配额设置显示 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
272 lines
8.9 KiB
JavaScript
272 lines
8.9 KiB
JavaScript
/**
|
||
* 加密工具模块
|
||
*
|
||
* 功能:
|
||
* - 使用 AES-256-GCM 加密敏感数据(OSS Access Key Secret)
|
||
* - 提供加密和解密函数
|
||
* - 自动处理初始化向量(IV)和认证标签
|
||
*
|
||
* 安全特性:
|
||
* - AES-256-GCM 提供认证加密(AEAD)
|
||
* - 每次加密使用随机 IV,防止模式泄露
|
||
* - 使用认证标签验证数据完整性
|
||
* - 密钥从环境变量读取,不存在硬编码
|
||
*
|
||
* @module utils/encryption
|
||
*/
|
||
|
||
const crypto = require('crypto');
|
||
|
||
/**
|
||
* 从环境变量获取加密密钥
|
||
*
|
||
* 要求:
|
||
* - 必须是 32 字节的十六进制字符串(64个字符)
|
||
* - 如果未设置或格式错误,启动时抛出错误
|
||
*
|
||
* @returns {Buffer} 32字节的加密密钥
|
||
* @throws {Error} 如果密钥未配置或格式错误
|
||
*/
|
||
function getEncryptionKey() {
|
||
const keyHex = process.env.ENCRYPTION_KEY;
|
||
|
||
if (!keyHex) {
|
||
throw new Error(`
|
||
╔═══════════════════════════════════════════════════════════════╗
|
||
║ ⚠️ 安全错误 ⚠️ ║
|
||
╠═══════════════════════════════════════════════════════════════╣
|
||
║ ENCRYPTION_KEY 未配置! ║
|
||
║ ║
|
||
║ 此密钥用于加密 OSS Access Key Secret 等敏感信息 ║
|
||
║ ║
|
||
║ 生成方法: ║
|
||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||
║ ║
|
||
║ 在 backend/.env 文件中添加: ║
|
||
║ ENCRYPTION_KEY=你生成的64位十六进制密钥 ║
|
||
╚═══════════════════════════════════════════════════════════════╝
|
||
`);
|
||
}
|
||
|
||
// 验证密钥格式(必须是64个十六进制字符)
|
||
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
|
||
throw new Error(`
|
||
╔═══════════════════════════════════════════════════════════════╗
|
||
║ ⚠️ 配置错误 ⚠️ ║
|
||
╠═══════════════════════════════════════════════════════════════╣
|
||
║ ENCRYPTION_KEY 格式错误! ║
|
||
║ ║
|
||
║ 要求: 64位十六进制字符串(32字节) ║
|
||
║ 当前长度: ${keyHex.length} 字符 ║
|
||
║ ║
|
||
║ 正确的生成方法: ║
|
||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||
╚═══════════════════════════════════════════════════════════════╝
|
||
`);
|
||
}
|
||
|
||
return Buffer.from(keyHex, 'hex');
|
||
}
|
||
|
||
/**
|
||
* 加密明文字符串
|
||
*
|
||
* 使用 AES-256-GCM 算法加密数据,输出格式:
|
||
* - Base64(IV + ciphertext + authTag)
|
||
* - IV: 12字节(随机)
|
||
* - ciphertext: 加密后的数据
|
||
* - authTag: 16字节(认证标签)
|
||
*
|
||
* @param {string} plaintext - 要加密的明文字符串
|
||
* @returns {string} Base64编码的加密结果(包含 IV 和 authTag)
|
||
* @throws {Error} 如果加密失败
|
||
*
|
||
* @example
|
||
* const encrypted = encryptSecret('my-secret-key');
|
||
* // 输出: 'base64-encoded-string-with-iv-and-tag'
|
||
*/
|
||
function encryptSecret(plaintext) {
|
||
try {
|
||
// 获取加密密钥
|
||
const key = getEncryptionKey();
|
||
|
||
// 生成随机初始化向量(IV)
|
||
// GCM 模式推荐 12 字节 IV
|
||
const iv = crypto.randomBytes(12);
|
||
|
||
// 创建加密器
|
||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||
|
||
// 加密数据
|
||
let encrypted = cipher.update(plaintext, 'utf8', 'binary');
|
||
encrypted += cipher.final('binary');
|
||
|
||
// 获取认证标签(用于验证数据完整性)
|
||
const authTag = cipher.getAuthTag();
|
||
|
||
// 组合:IV + encrypted + authTag
|
||
const combined = Buffer.concat([
|
||
iv,
|
||
Buffer.from(encrypted, 'binary'),
|
||
authTag
|
||
]);
|
||
|
||
// 返回 Base64 编码的结果
|
||
return combined.toString('base64');
|
||
} catch (error) {
|
||
console.error('[加密] 加密失败:', error);
|
||
throw new Error('数据加密失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解密密文字符串
|
||
*
|
||
* 解密由 encryptSecret() 加密的数据
|
||
* 自动验证认证标签,确保数据完整性
|
||
*
|
||
* @param {string} ciphertext - Base64编码的密文(由 encryptSecret 生成)
|
||
* @returns {string} 解密后的明文字符串
|
||
* @throws {Error} 如果解密失败或认证标签验证失败
|
||
*
|
||
* @example
|
||
* const decrypted = decryptSecret(encrypted);
|
||
* // 输出: 'my-secret-key'
|
||
*/
|
||
function decryptSecret(ciphertext) {
|
||
try {
|
||
// 如果是 null 或 undefined,直接返回
|
||
if (!ciphertext) {
|
||
return ciphertext;
|
||
}
|
||
|
||
// 检查是否为加密格式(Base64)
|
||
// 如果不是 Base64,可能是旧数据(明文),直接返回
|
||
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
|
||
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
|
||
return ciphertext;
|
||
}
|
||
|
||
// 获取加密密钥
|
||
const key = getEncryptionKey();
|
||
|
||
// 解析 Base64
|
||
const combined = Buffer.from(ciphertext, 'base64');
|
||
|
||
// 提取各部分
|
||
// IV: 前 12 字节
|
||
const iv = combined.slice(0, 12);
|
||
|
||
// authTag: 最后 16 字节
|
||
const authTag = combined.slice(-16);
|
||
|
||
// ciphertext: 中间部分
|
||
const encrypted = combined.slice(12, -16);
|
||
|
||
// 创建解密器
|
||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||
|
||
// 设置认证标签
|
||
decipher.setAuthTag(authTag);
|
||
|
||
// 解密数据
|
||
let decrypted = decipher.update(encrypted, 'binary', 'utf8');
|
||
decrypted += decipher.final('utf8');
|
||
|
||
return decrypted;
|
||
} catch (error) {
|
||
// 如果解密失败,可能是旧数据(明文),直接返回
|
||
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
|
||
|
||
// 在开发环境抛出错误,生产环境尝试返回原值
|
||
if (process.env.NODE_ENV === 'production') {
|
||
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
|
||
return ciphertext;
|
||
}
|
||
|
||
throw new Error('数据解密失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证加密系统是否正常工作
|
||
*
|
||
* 在应用启动时调用,确保:
|
||
* 1. ENCRYPTION_KEY 已配置
|
||
* 2. 加密/解密功能正常
|
||
*
|
||
* @returns {boolean} true 如果验证通过
|
||
* @throws {Error} 如果验证失败
|
||
*/
|
||
function validateEncryption() {
|
||
try {
|
||
const testData = 'test-secret-123';
|
||
|
||
// 测试加密
|
||
const encrypted = encryptSecret(testData);
|
||
|
||
// 验证加密结果不为空且不等于原文
|
||
if (!encrypted || encrypted === testData) {
|
||
throw new Error('加密结果异常');
|
||
}
|
||
|
||
// 测试解密
|
||
const decrypted = decryptSecret(encrypted);
|
||
|
||
// 验证解密结果等于原文
|
||
if (decrypted !== testData) {
|
||
throw new Error('解密结果不匹配');
|
||
}
|
||
|
||
console.log('[安全] ✓ 加密系统验证通过');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('[安全] ✗ 加密系统验证失败:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查字符串是否已加密
|
||
*
|
||
* 通过格式判断是否为加密数据
|
||
* 注意:这不是加密学验证,仅用于提示
|
||
*
|
||
* @param {string} data - 要检查的数据
|
||
* @returns {boolean} true 如果看起来像是加密数据
|
||
*/
|
||
function isEncrypted(data) {
|
||
if (!data || typeof data !== 'string') {
|
||
return false;
|
||
}
|
||
|
||
// 加密后的数据特征:
|
||
// 1. 是有效的 Base64
|
||
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64)
|
||
// 3. 通常会比原文长
|
||
|
||
try {
|
||
// 尝试解码 Base64
|
||
const buffer = Buffer.from(data, 'base64');
|
||
|
||
// 检查长度(至少包含 IV + authTag)
|
||
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
|
||
// Base64编码后: 29 * 4/3 ≈ 39 字符
|
||
if (buffer.length < 29) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
encryptSecret,
|
||
decryptSecret,
|
||
validateEncryption,
|
||
isEncrypted,
|
||
getEncryptionKey
|
||
};
|