chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions

View 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;
}
}