/** * Credential Encryption Module * * Provides AES-256-GCM encryption for sensitive credentials at rest. * 1. No file, no env var: Generate key, save to file (initial setup) * 2. File exists, no env var: Use file key (unchanged) * 3. No file, env var set: Use env var key, do NOT save to file * 4. File exists, env var set (same key): Use key, delete file (env var is source of truth) * 5. File exists, env var set (different key): Re-encrypt with env var key, delete file * * Once a user provides ENCRYPTION_KEY, the key file is removed - the key lives only in memory */ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'; import { join, dirname } from 'node:path'; // ============================================================================= // CONSTANTS // ============================================================================= /** Encryption algorithm: AES-256 with GCM mode (authenticated encryption) */ const ALGORITHM = 'aes-256-gcm'; /** Initialization vector length in bytes */ const IV_LENGTH = 12; /** Authentication tag length in bytes */ const AUTH_TAG_LENGTH = 16; /** Encryption key length in bytes (256 bits) */ const KEY_LENGTH = 32; /** Prefix for encrypted values (version 1) */ const ENCRYPTED_PREFIX = 'enc:v1:'; /** File name for auto-generated encryption key */ const KEY_FILE_NAME = '.encryption_key'; let cachedKey: Buffer | null = null; /** Pending key rotation state (set when env var differs from file) */ let pendingKeyRotation: { oldKey: Buffer; newKey: Buffer } | null = null; function getDataDir(): string { return process.env.DATA_DIR || './data'; } /** * Get or create the encryption key. * * Hybrid key management approach: * 1. No file, no env var: Generate key, save to file (initial setup) * 2. File exists, no env var: Use file key (unchanged) * 3. No file, env var set: Use env var key, do NOT save to file * 4. File exists, env var set (same key): Use key, delete file (env var is source of truth) * 5. File exists, env var set (different key): Re-encrypt with env var key, delete file after migration * * Once user provides ENCRYPTION_KEY, the key file is removed - the key lives * only in memory from the environment variable. */ function getOrCreateKey(): Buffer { // Return cached key if available if (cachedKey) { return cachedKey; } const dataDir = getDataDir(); const keyPath = join(dataDir, KEY_FILE_NAME); const envKey = process.env.ENCRYPTION_KEY; // 1. File exists? if (existsSync(keyPath)) { try { const fileKey = readFileSync(keyPath); if (fileKey.length !== KEY_LENGTH) { throw new Error(`Key file has invalid length: expected ${KEY_LENGTH}, got ${fileKey.length}`); } // Env var also set? Env var takes over, file will be deleted if (envKey) { try { const envKeyBuffer = Buffer.from(envKey, 'base64'); if (envKeyBuffer.length !== KEY_LENGTH) { console.warn('[Encryption] WARNING: ENCRYPTION_KEY env var has invalid length (ignored)'); // Fall through to use file key } else if (!fileKey.equals(envKeyBuffer)) { // Different key - trigger key rotation mode // File will be deleted after re-encryption in migrateCredentials() console.log('[Encryption] Key change detected - will re-encrypt and remove key file'); pendingKeyRotation = { oldKey: fileKey, newKey: envKeyBuffer }; // Return OLD key for decryption first cachedKey = fileKey; return cachedKey; } else { // Same key - delete file immediately, env var is now source of truth try { unlinkSync(keyPath); console.log('[Encryption] Using ENCRYPTION_KEY from environment, removed key file'); } catch (unlinkError) { const msg = unlinkError instanceof Error ? unlinkError.message : String(unlinkError); console.warn(`[Encryption] Could not remove key file: ${msg}`); } cachedKey = envKeyBuffer; return cachedKey; } } catch { console.warn('[Encryption] WARNING: ENCRYPTION_KEY env var is invalid (ignored)'); } } // No env var or invalid env var - use file key cachedKey = fileKey; console.log('[Encryption] Using encryption key from', keyPath); return cachedKey; } catch (error) { const msg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read encryption key from ${keyPath}: ${msg}`); } } // 2. No file - env var set? Use it WITHOUT saving to file if (envKey) { try { const keyBuffer = Buffer.from(envKey, 'base64'); if (keyBuffer.length !== KEY_LENGTH) { throw new Error(`ENCRYPTION_KEY must be exactly ${KEY_LENGTH} bytes when decoded`); } cachedKey = keyBuffer; console.log('[Encryption] Using ENCRYPTION_KEY from environment (not persisted to disk)'); return cachedKey; } catch (error) { const msg = error instanceof Error ? error.message : String(error); throw new Error(`Invalid ENCRYPTION_KEY: ${msg}`); } } // 3. No file, no env var - generate new key and save to file (initial setup) // Ensure data directory exists before writing if (!existsSync(dataDir)) { mkdirSync(dataDir, { recursive: true }); } console.log('[Encryption] Generating new encryption key...'); cachedKey = randomBytes(KEY_LENGTH); // Save key with restricted permissions (0600 = owner read/write only) try { writeFileSync(keyPath, cachedKey, { mode: 0o600 }); console.log('[Encryption] Saved new encryption key to', keyPath); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[Encryption] Warning: Failed to save encryption key to ${keyPath}: ${msg}`); console.error('[Encryption] Encryption will work for this session but keys will be regenerated on restart'); } return cachedKey; } // ============================================================================= // ENCRYPTION / DECRYPTION // ============================================================================= /** * Encrypt a plain text value using AES-256-GCM. * * @param plaintext - The value to encrypt (or null/empty) * @returns Encrypted value with "enc:v1:" prefix, or null/empty if input was null/empty * * Format: enc:v1: */ export function encrypt(plaintext: string | null | undefined): string | null { // Pass through null/undefined/empty values if (plaintext === null || plaintext === undefined || plaintext === '') { return plaintext as string | null; } // Don't double-encrypt if (plaintext.startsWith(ENCRYPTED_PREFIX)) { return plaintext; } const key = getOrCreateKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const ciphertext = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final() ]); const authTag = cipher.getAuthTag(); // Combine: iv (12 bytes) + authTag (16 bytes) + ciphertext const combined = Buffer.concat([iv, authTag, ciphertext]); return ENCRYPTED_PREFIX + combined.toString('base64'); } /** * Decrypt a value that may be encrypted or plain text. * * If the value doesn't have the "enc:v1:" prefix, it's assumed to be plain text and returned as-is. * * @param value - The value to decrypt (encrypted with prefix, plain text, null, or empty) * @returns Decrypted value, or the original value if not encrypted, or null if input was null */ export function decrypt(value: string | null | undefined): string | null { // Pass through null/undefined/empty values if (value === null || value === undefined || value === '') { return value as string | null; } // BACKWARDS COMPATIBILITY: If no prefix, it's plain text - return as-is if (!value.startsWith(ENCRYPTED_PREFIX)) { return value; } // Extract the base64 payload after the prefix const payload = value.substring(ENCRYPTED_PREFIX.length); let combined: Buffer; try { combined = Buffer.from(payload, 'base64'); } catch { console.error('[Encryption] Failed to decode base64 payload'); // Return original value to avoid data loss return value; } // Validate minimum length: iv (12) + authTag (16) + at least 1 byte ciphertext if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) { console.error('[Encryption] Encrypted payload is too short'); return value; } // Extract components const iv = combined.subarray(0, IV_LENGTH); const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); try { const key = getOrCreateKey(); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([ decipher.update(ciphertext), decipher.final() ]); return decrypted.toString('utf8'); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[Encryption] Decryption failed: ${msg}`); // Return original value to avoid data loss (might be corrupted or wrong key) return value; } } /** * Check if a value is encrypted (has the encryption prefix). * * @param value - The value to check * @returns true if the value appears to be encrypted */ export function isEncrypted(value: string | null | undefined): boolean { return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX); } /** * Generate a new encryption key and return it as base64. * Useful for generating ENCRYPTION_KEY environment variable values. * * @returns Base64-encoded 32-byte encryption key */ export function generateKey(): string { return randomBytes(KEY_LENGTH).toString('base64'); } /** * Clear the cached encryption key. * Primarily for testing purposes. */ export function clearKeyCache(): void { cachedKey = null; pendingKeyRotation = null; } /** * Initialize encryption and migrate unencrypted credentials. * * 1. Ensures encryption key exists (generates or loads from file/env var) * 2. Checks for pending key rotation (re-encrypts with new key, removes key file) * 3. Encrypts any values that don't have the "enc:v1:" prefix * * This is idempotent - safe to call on every startup. */ export async function migrateCredentials(): Promise { // IMPORTANT: Always initialize the key on startup, even if there are no credentials yet. // This ensures the key file is created before any credentials are added. getOrCreateKey(); console.log('[Encryption] Checking for unencrypted credentials...'); // Import database dynamically to avoid circular dependency const { db, eq, registries, gitCredentials, environments, oidcConfig, ldapConfig, notificationSettings, stackEnvironmentVariables } = await import('./db/drizzle.js'); let migrated = 0; const keyPath = join(getDataDir(), KEY_FILE_NAME); // Check for key rotation first if (pendingKeyRotation) { console.log('[Encryption] Performing key rotation - re-encrypting all credentials...'); // Decrypt everything with old key, then switch to new key // The old key is already cached, so decrypt will use it // 1. Collect all encrypted values (we need to decrypt then re-encrypt) const allEncrypted: Array<{ table: string; id: number; field: string; value: string; }> = []; const regs = await db.select().from(registries); for (const reg of regs) { if (reg.password && isEncrypted(reg.password)) { allEncrypted.push({ table: 'registries', id: reg.id, field: 'password', value: reg.password }); } } const gitCreds = await db.select().from(gitCredentials); for (const cred of gitCreds) { if (cred.password && isEncrypted(cred.password)) { allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'password', value: cred.password }); } if (cred.sshPrivateKey && isEncrypted(cred.sshPrivateKey)) { allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'sshPrivateKey', value: cred.sshPrivateKey }); } if (cred.sshPassphrase && isEncrypted(cred.sshPassphrase)) { allEncrypted.push({ table: 'gitCredentials', id: cred.id, field: 'sshPassphrase', value: cred.sshPassphrase }); } } const envs = await db.select().from(environments); for (const env of envs) { if (env.hawserToken && isEncrypted(env.hawserToken)) { allEncrypted.push({ table: 'environments', id: env.id, field: 'hawserToken', value: env.hawserToken }); } if (env.tlsKey && isEncrypted(env.tlsKey)) { allEncrypted.push({ table: 'environments', id: env.id, field: 'tlsKey', value: env.tlsKey }); } } const oidcConfigs = await db.select().from(oidcConfig); for (const config of oidcConfigs) { if (config.clientSecret && isEncrypted(config.clientSecret)) { allEncrypted.push({ table: 'oidcConfig', id: config.id, field: 'clientSecret', value: config.clientSecret }); } } const ldapConfigs = await db.select().from(ldapConfig); for (const config of ldapConfigs) { if (config.bindPassword && isEncrypted(config.bindPassword)) { allEncrypted.push({ table: 'ldapConfig', id: config.id, field: 'bindPassword', value: config.bindPassword }); } } const notifSettings = await db.select().from(notificationSettings); for (const notif of notifSettings) { if (notif.config) { try { const config = JSON.parse(notif.config); if (config.smtpPassword && isEncrypted(config.smtpPassword)) { allEncrypted.push({ table: 'notificationSettings', id: notif.id, field: 'config.smtpPassword', value: config.smtpPassword }); } } catch { // Invalid JSON, skip } } } const stackEnvVars = await db.select().from(stackEnvironmentVariables); for (const envVar of stackEnvVars) { if (envVar.isSecret && envVar.value && isEncrypted(envVar.value)) { allEncrypted.push({ table: 'stackEnvironmentVariables', id: envVar.id, field: 'value', value: envVar.value }); } } // Decrypt all values with old key const decryptedValues: Map = new Map(); for (const item of allEncrypted) { const decrypted = decrypt(item.value); if (decrypted) { decryptedValues.set(`${item.table}:${item.id}:${item.field}`, decrypted); } } // Switch to new key cachedKey = pendingKeyRotation.newKey; // Re-encrypt and update all values for (const item of allEncrypted) { const decrypted = decryptedValues.get(`${item.table}:${item.id}:${item.field}`); if (decrypted) { const reEncrypted = encrypt(decrypted); // Update database based on table if (item.table === 'registries') { await db.update(registries).set({ [item.field]: reEncrypted }).where(eq(registries.id, item.id)); } else if (item.table === 'gitCredentials') { await db.update(gitCredentials).set({ [item.field]: reEncrypted }).where(eq(gitCredentials.id, item.id)); } else if (item.table === 'environments') { await db.update(environments).set({ [item.field]: reEncrypted }).where(eq(environments.id, item.id)); } else if (item.table === 'oidcConfig') { await db.update(oidcConfig).set({ [item.field]: reEncrypted }).where(eq(oidcConfig.id, item.id)); } else if (item.table === 'ldapConfig') { await db.update(ldapConfig).set({ [item.field]: reEncrypted }).where(eq(ldapConfig.id, item.id)); } else if (item.table === 'notificationSettings' && item.field === 'config.smtpPassword') { // Need to update the JSON field const notif = notifSettings.find(n => n.id === item.id); if (notif) { const config = JSON.parse(notif.config); config.smtpPassword = reEncrypted; await db.update(notificationSettings).set({ config: JSON.stringify(config) }).where(eq(notificationSettings.id, item.id)); } } else if (item.table === 'stackEnvironmentVariables') { await db.update(stackEnvironmentVariables).set({ value: reEncrypted }).where(eq(stackEnvironmentVariables.id, item.id)); } migrated++; } } // Delete key file - env var is now the source of truth if (existsSync(keyPath)) { try { unlinkSync(keyPath); console.log('[Encryption] Deleted key file - now using ENCRYPTION_KEY from environment only'); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.warn(`[Encryption] Could not delete key file: ${msg}`); } } pendingKeyRotation = null; if (migrated > 0) { console.log(`[Encryption] Re-encrypted ${migrated} credentials with new key`); } else { console.log('[Encryption] Key rotation complete (no credentials to re-encrypt)'); } return; } const regs = await db.select().from(registries); for (const reg of regs) { if (reg.password && !isEncrypted(reg.password)) { await db.update(registries) .set({ password: encrypt(reg.password) }) .where(eq(registries.id, reg.id)); migrated++; } } const gitCreds = await db.select().from(gitCredentials); for (const cred of gitCreds) { const updates: Record = {}; if (cred.password && !isEncrypted(cred.password)) { updates.password = encrypt(cred.password); migrated++; } if (cred.sshPrivateKey && !isEncrypted(cred.sshPrivateKey)) { updates.sshPrivateKey = encrypt(cred.sshPrivateKey); migrated++; } if (cred.sshPassphrase && !isEncrypted(cred.sshPassphrase)) { updates.sshPassphrase = encrypt(cred.sshPassphrase); migrated++; } if (Object.keys(updates).length > 0) { await db.update(gitCredentials).set(updates).where(eq(gitCredentials.id, cred.id)); } } const envs = await db.select().from(environments); for (const env of envs) { const updates: Record = {}; if (env.hawserToken && !isEncrypted(env.hawserToken)) { updates.hawserToken = encrypt(env.hawserToken); migrated++; } if (env.tlsKey && !isEncrypted(env.tlsKey)) { updates.tlsKey = encrypt(env.tlsKey); migrated++; } if (Object.keys(updates).length > 0) { await db.update(environments).set(updates).where(eq(environments.id, env.id)); } } const oidcConfigs = await db.select().from(oidcConfig); for (const config of oidcConfigs) { if (config.clientSecret && !isEncrypted(config.clientSecret)) { await db.update(oidcConfig) .set({ clientSecret: encrypt(config.clientSecret) }) .where(eq(oidcConfig.id, config.id)); migrated++; } } const ldapConfigs = await db.select().from(ldapConfig); for (const config of ldapConfigs) { if (config.bindPassword && !isEncrypted(config.bindPassword)) { await db.update(ldapConfig) .set({ bindPassword: encrypt(config.bindPassword) }) .where(eq(ldapConfig.id, config.id)); migrated++; } } const notifSettings = await db.select().from(notificationSettings); for (const notif of notifSettings) { if (notif.config) { try { const config = JSON.parse(notif.config); if (config.smtpPassword && !isEncrypted(config.smtpPassword)) { config.smtpPassword = encrypt(config.smtpPassword); await db.update(notificationSettings) .set({ config: JSON.stringify(config) }) .where(eq(notificationSettings.id, notif.id)); migrated++; } } catch { // Invalid JSON, skip } } } const stackEnvVars = await db.select().from(stackEnvironmentVariables); for (const envVar of stackEnvVars) { if (envVar.isSecret && envVar.value && !isEncrypted(envVar.value)) { await db.update(stackEnvironmentVariables) .set({ value: encrypt(envVar.value) }) .where(eq(stackEnvironmentVariables.id, envVar.id)); migrated++; } } if (migrated > 0) { console.log(`[Encryption] Migrated ${migrated} credentials to encrypted storage`); } }