chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
111
packages/backend/src/utils/crypto.ts
Normal file
111
packages/backend/src/utils/crypto.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user