| import crypto from 'crypto'; |
| import Database from 'better-sqlite3'; |
|
|
| const ALGORITHM = 'aes-256-gcm'; |
|
|
| let cachedKey: Buffer | null = null; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const KEY_BYTES = 32; |
| const KEY_HEX_LEN = KEY_BYTES * 2; |
| const PLACEHOLDER_KEY = 'your-64-char-hex-key-here'; |
|
|
| function parseHexKey(value: string, source: 'env' | 'db'): Buffer { |
| if (value.length !== KEY_HEX_LEN || !/^[0-9a-fA-F]+$/.test(value)) { |
| throw new Error( |
| `Invalid ENCRYPTION_KEY (${source}): expected ${KEY_HEX_LEN} hex chars (32 bytes), got ${value.length} chars. ` + |
| `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`, |
| ); |
| } |
| return Buffer.from(value, 'hex'); |
| } |
|
|
| function isDevFallbackAllowed(): boolean { |
| return process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production'; |
| } |
|
|
| function missingKeyError(): Error { |
| return new Error( |
| 'ENCRYPTION_KEY is required for API key encryption. ' + |
| `Set a ${KEY_HEX_LEN}-char hex key, or set DEV_MODE=true outside production to allow a local DB-stored fallback key.`, |
| ); |
| } |
|
|
| |
| |
| |
| |
| export function initEncryptionKey(db: Database.Database): void { |
| |
| const envKey = process.env.ENCRYPTION_KEY; |
| if (envKey && envKey !== PLACEHOLDER_KEY) { |
| cachedKey = parseHexKey(envKey, 'env'); |
| return; |
| } |
|
|
| if (!isDevFallbackAllowed()) { |
| throw missingKeyError(); |
| } |
|
|
| |
| const row = db.prepare("SELECT value FROM settings WHERE key = 'encryption_key'").get() as { value: string } | undefined; |
| if (row) { |
| cachedKey = parseHexKey(row.value, 'db'); |
| return; |
| } |
|
|
| |
| cachedKey = crypto.randomBytes(KEY_BYTES); |
| db.prepare("INSERT INTO settings (key, value) VALUES ('encryption_key', ?)").run(cachedKey.toString('hex')); |
| } |
|
|
| function getEncryptionKey(): Buffer { |
| if (!cachedKey) { |
| throw new Error('Encryption key not initialized. Call initEncryptionKey() first.'); |
| } |
| return cachedKey; |
| } |
|
|
| export function encrypt(text: string): { encrypted: string; iv: string; authTag: string } { |
| const key = getEncryptionKey(); |
| const iv = crypto.randomBytes(16); |
| const cipher = crypto.createCipheriv(ALGORITHM, key, iv); |
|
|
| let encrypted = cipher.update(text, 'utf8', 'hex'); |
| encrypted += cipher.final('hex'); |
| const authTag = cipher.getAuthTag().toString('hex'); |
|
|
| return { |
| encrypted, |
| iv: iv.toString('hex'), |
| authTag, |
| }; |
| } |
|
|
| export function decrypt(encrypted: string, iv: string, authTag: string): string { |
| const key = getEncryptionKey(); |
| const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); |
| decipher.setAuthTag(Buffer.from(authTag, 'hex')); |
|
|
| let decrypted = decipher.update(encrypted, 'hex', 'utf8'); |
| decrypted += decipher.final('utf8'); |
| return decrypted; |
| } |
|
|
| export function maskKey(key: string): string { |
| if (key.length <= 8) return '****' + key.slice(-4); |
| return key.slice(0, 4) + '...' + key.slice(-4); |
| } |
|
|