Files
CloudSearch/packages/backend/src/utils/crypto.ts

112 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AES-256-GCM 加解密工具
*
* 用于保护数据库中存储的网盘 Cookie。
* 加密密钥从环境变量 COOKIE_ENCRYPTION_KEY 读取,
* 未设置时自动生成随机密钥(仅当前进程有效,重启后旧数据不可解密)。
*
* 生产环境必须设置 COOKIE_ENCRYPTION_KEY
*/
import * as crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit nonce for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
const KEY_LENGTH = 32; // 256-bit key
let ENCRYPTION_KEY: Buffer | null = null;
function getKey(): Buffer {
if (ENCRYPTION_KEY) return ENCRYPTION_KEY;
const envKey = process.env.COOKIE_ENCRYPTION_KEY;
if (envKey && envKey.length >= 32) {
// Use SHA-256 to derive a consistent 32-byte key from any length input
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)');
} else if (envKey) {
// Short key: still use SHA-256
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
} else {
// Default stable key (not ephemeral) — data survives container restart
ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest();
console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)');
}
return ENCRYPTION_KEY;
}
/**
* Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag).
*/
export function encrypt(plaintext: string): string {
if (!plaintext) return '';
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Format: iv (12) + tag (16) + ciphertext
const combined = Buffer.concat([iv, tag, encrypted]);
return combined.toString('base64');
}
/**
* Decrypt base64-encoded ciphertext. Returns original plaintext.
* Returns empty string if decryption fails (corrupted data or wrong key).
*/
export function decrypt(encoded: string): string {
if (!encoded) return '';
try {
const key = getKey();
const combined = Buffer.from(encoded, 'base64');
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)');
// Legacy data: stored as plaintext before encryption was added
return encoded;
}
const iv = combined.subarray(0, IV_LENGTH);
const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch (err: any) {
// If it looks like base64 but decryption fails, it might be legacy plaintext
// stored before encryption was enabled. Try returning as-is.
if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) {
console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is');
return encoded;
}
console.error('[Crypto] Decryption error:', err.message);
return '';
}
}
/**
* Check if a string appears to be encrypted (base64 with IV+tag prefix).
* Used for migration: re-encrypt legacy plaintext cookies.
*/
export function isEncrypted(value: string): boolean {
if (!value) return false;
try {
const combined = Buffer.from(value, 'base64');
return combined.length > IV_LENGTH + TAG_LENGTH;
} catch {
return false;
}
}