streamflow/backend/utils/encryption.js

294 lines
8.4 KiB
JavaScript
Raw Permalink Normal View History

/**
* Centralized Encryption Utility for CWE-311 Compliance
* Provides AES-256-GCM encryption for sensitive data at rest
*
* Security Features:
* - AES-256-GCM authenticated encryption
* - Unique IV per encryption operation
* - HMAC authentication tags
* - Key rotation support
* - Secure key derivation from master secret
*/
const crypto = require('crypto');
const logger = require('./logger');
// Encryption configuration
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 16; // 128 bits for GCM
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 32;
/**
* Get master encryption key from environment or generate default
* SECURITY WARNING: Always set ENCRYPTION_MASTER_KEY in production!
*/
function getMasterKey() {
const envKey = process.env.ENCRYPTION_MASTER_KEY;
if (!envKey) {
logger.warn('⚠️ ENCRYPTION_MASTER_KEY not set - using default (insecure for production)');
// Use JWT_SECRET as fallback, but warn about it
const fallbackKey = process.env.JWT_SECRET || 'default-insecure-key-change-in-production';
return crypto.createHash('sha256').update(fallbackKey + '-encryption-v1').digest();
}
// Derive proper key from master secret
return crypto.createHash('sha256').update(envKey).digest();
}
/**
* Derive encryption key for specific purpose using HKDF-like approach
* @param {String} purpose - Purpose identifier (e.g., 'settings', 'vpn', 'api-tokens')
* @returns {Buffer} Derived encryption key
*/
function deriveKey(purpose) {
const masterKey = getMasterKey();
const info = Buffer.from(purpose + '-v1', 'utf8');
return crypto.createHmac('sha256', masterKey)
.update(info)
.digest();
}
/**
* Encrypt sensitive data with AES-256-GCM
* @param {String} plaintext - Data to encrypt
* @param {String} purpose - Purpose identifier for key derivation
* @returns {String} Encrypted data in format: salt:iv:authTag:ciphertext (all hex encoded)
*/
function encrypt(plaintext, purpose = 'default') {
try {
if (!plaintext) {
return null;
}
// Generate random salt and IV for this encryption operation
const salt = crypto.randomBytes(SALT_LENGTH);
const iv = crypto.randomBytes(IV_LENGTH);
// Derive encryption key with salt for additional security
const masterKey = getMasterKey();
const derivedKey = crypto.pbkdf2Sync(
masterKey,
salt,
100000, // iterations
KEY_LENGTH,
'sha256'
);
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, derivedKey, iv);
// Encrypt
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Get authentication tag (GCM provides authenticated encryption)
const authTag = cipher.getAuthTag();
// Return format: salt:iv:authTag:ciphertext
return [
salt.toString('hex'),
iv.toString('hex'),
authTag.toString('hex'),
encrypted
].join(':');
} catch (error) {
logger.error('Encryption error:', { purpose, error: error.message });
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypt data encrypted with encrypt()
* @param {String} encryptedData - Encrypted data in format: salt:iv:authTag:ciphertext
* @param {String} purpose - Purpose identifier (must match encryption purpose)
* @returns {String} Decrypted plaintext
*/
function decrypt(encryptedData, purpose = 'default') {
try {
if (!encryptedData) {
return null;
}
// Parse encrypted data
const parts = encryptedData.split(':');
if (parts.length !== 4) {
throw new Error('Invalid encrypted data format');
}
const salt = Buffer.from(parts[0], 'hex');
const iv = Buffer.from(parts[1], 'hex');
const authTag = Buffer.from(parts[2], 'hex');
const encrypted = parts[3];
// Validate lengths
if (salt.length !== SALT_LENGTH || iv.length !== IV_LENGTH || authTag.length !== AUTH_TAG_LENGTH) {
throw new Error('Invalid encrypted data structure');
}
// Derive the same key used for encryption
const masterKey = getMasterKey();
const derivedKey = crypto.pbkdf2Sync(
masterKey,
salt,
100000,
KEY_LENGTH,
'sha256'
);
// Create decipher
const decipher = crypto.createDecipheriv(ALGORITHM, derivedKey, iv);
decipher.setAuthTag(authTag);
// Decrypt
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
logger.error('Decryption error:', { purpose, error: error.message });
throw new Error('Failed to decrypt data');
}
}
/**
* Encrypt sensitive settings value
* Automatically detects if already encrypted
*/
function encryptSetting(value, key) {
if (!value) return null;
// Don't encrypt if already encrypted (starts with salt:iv:authTag:ciphertext format)
if (typeof value === 'string' && value.split(':').length === 4) {
const parts = value.split(':');
if (parts[0].length === SALT_LENGTH * 2 && parts[1].length === IV_LENGTH * 2) {
return value; // Already encrypted
}
}
const plaintext = typeof value === 'string' ? value : JSON.stringify(value);
return encrypt(plaintext, `setting:${key}`);
}
/**
* Decrypt sensitive setting value
*/
function decryptSetting(encryptedValue, key) {
if (!encryptedValue) return null;
try {
const decrypted = decrypt(encryptedValue, `setting:${key}`);
// Try to parse as JSON if it looks like JSON
if (decrypted && (decrypted.startsWith('{') || decrypted.startsWith('['))) {
try {
return JSON.parse(decrypted);
} catch {
return decrypted;
}
}
return decrypted;
} catch (error) {
// If decryption fails, value might not be encrypted (migration scenario)
logger.warn(`Failed to decrypt setting ${key}, returning as-is`);
return encryptedValue;
}
}
/**
* Check if encryption key is properly configured
*/
function isEncryptionConfigured() {
return !!process.env.ENCRYPTION_MASTER_KEY;
}
/**
* Get encryption health status
*/
function getEncryptionStatus() {
const configured = isEncryptionConfigured();
return {
configured,
algorithm: ALGORITHM,
keySize: KEY_LENGTH * 8, // bits
status: configured ? 'secure' : 'default-key',
warning: configured ? null : 'Using default encryption key - set ENCRYPTION_MASTER_KEY in production',
recommendations: configured ? [] : [
'Set ENCRYPTION_MASTER_KEY environment variable',
'Use a strong random key (at least 32 characters)',
'Store the key securely (e.g., Docker secrets, AWS Secrets Manager)'
]
};
}
/**
* Re-encrypt data with new master key (for key rotation)
* @param {String} oldEncryptedData - Data encrypted with old key
* @param {String} purpose - Purpose identifier
* @param {String} oldMasterKey - Old master key (optional, uses current env if not provided)
* @returns {String} Data re-encrypted with current master key
*/
function reEncrypt(oldEncryptedData, purpose = 'default', oldMasterKey = null) {
try {
// Temporarily swap master key if provided
const originalKey = process.env.ENCRYPTION_MASTER_KEY;
if (oldMasterKey) {
process.env.ENCRYPTION_MASTER_KEY = oldMasterKey;
}
// Decrypt with old key
const plaintext = decrypt(oldEncryptedData, purpose);
// Restore original key
if (oldMasterKey) {
process.env.ENCRYPTION_MASTER_KEY = originalKey;
}
// Encrypt with current key
return encrypt(plaintext, purpose);
} catch (error) {
logger.error('Re-encryption error:', { purpose, error: error.message });
throw new Error('Failed to re-encrypt data');
}
}
/**
* Hash sensitive data for comparison (one-way, cannot be decrypted)
* Use for data that needs to be compared but not retrieved (e.g., backup codes)
*/
function hashSensitiveData(data) {
return crypto.createHash('sha256').update(data).digest('hex');
}
/**
* Generate cryptographically secure random token
*/
function generateSecureToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
module.exports = {
encrypt,
decrypt,
encryptSetting,
decryptSetting,
deriveKey,
isEncryptionConfigured,
getEncryptionStatus,
reEncrypt,
hashSensitiveData,
generateSecureToken,
// Export for legacy VPN compatibility
encryptVPN: (data) => encrypt(JSON.stringify(data), 'vpn'),
decryptVPN: (data) => JSON.parse(decrypt(data, 'vpn'))
};