Files
CloudSearch/source_clean/src/utils/crypto.ts

122 lines
4.4 KiB
TypeScript

/**
* AES-256-GCM encryption/decryption for protecting cloud drive cookies stored in DB.
*
* Encryption key is derived from COOKIE_ENCRYPTION_KEY env var via SHA-256.
* If unset, uses a built-in default key (stable across container restarts).
* Production MUST set COOKIE_ENCRYPTION_KEY!
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
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) {
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)');
} else if (envKey) {
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
} else {
// Auto-generate a random key and persist to file
const keyFile = path.join(process.env.DATA_DIR || '/app/data', 'encryption.key');
try {
if (fs.existsSync(keyFile)) {
const savedKey = fs.readFileSync(keyFile, 'utf8').trim();
if (savedKey.length >= 32) {
ENCRYPTION_KEY = crypto.createHash('sha256').update(savedKey).digest();
console.log('[Crypto] Cookie encryption enabled (loaded from encryption.key)');
return ENCRYPTION_KEY;
}
}
} catch (_) { /* file read failed, will generate new key */ }
const autoKey = crypto.randomBytes(32).toString('hex');
try {
const dir = path.dirname(keyFile);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(keyFile, autoKey);
console.log('[Crypto] Auto-generated encryption key saved to encryption.key');
} catch (e: any) {
console.log('[Crypto] Could not persist key, using ephemeral:', e.message);
}
ENCRYPTION_KEY = crypto.createHash('sha256').update(autoKey).digest();
console.log('[Crypto] Cookie encryption enabled (auto-generated key)');
}
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)');
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 (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;
}
}