294 lines
8.4 KiB
JavaScript
294 lines
8.4 KiB
JavaScript
|
|
/**
|
||
|
|
* 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'))
|
||
|
|
};
|