122 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
}
|