Files
dockhand-upstream/src/lib/server/encryption.ts
T
2026-01-20 15:39:08 +01:00

566 lines
19 KiB
TypeScript

/**
* 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:<base64(iv + authTag + ciphertext)>
*/
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<void> {
// 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<string, string> = 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<string, string | null> = {};
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<string, string | null> = {};
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`);
}
}