godcrm/backend/utils/encryption.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

187 lines
5.7 KiB
JavaScript

// backend/utils/encryption.js
// ADR-064 Phase 1, Task 5: AES-256-GCM Encryption for Data Source Credentials
// Created: 2026-01-31
import crypto from 'crypto';
import { logger } from './logger.js';
const encryptionLogger = logger.child({ module: 'encryption' });
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96 bits recommended for GCM
const AUTH_TAG_LENGTH = 16; // 128-bit authentication tag
const KEY_LENGTH = 32; // 256 bits for AES-256
// Fallback key for development only - logged as warning
const DEV_FALLBACK_KEY = 'DEV_ONLY_FALLBACK_KEY_NOT_SECURE';
/**
* Get the master encryption key from environment.
* In production: throws if MASTER_ENCRYPTION_KEY is not set.
* In development/test: warns and uses a fallback key.
* @returns {Buffer} 32-byte key buffer
*/
function getMasterKey() {
const rawKey = process.env.MASTER_ENCRYPTION_KEY;
const isProduction = process.env.NODE_ENV === 'production';
if (!rawKey) {
if (isProduction) {
throw new Error(
'MASTER_ENCRYPTION_KEY must be set in environment for production. ' +
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
}
// Dev/test: warn and use fallback
encryptionLogger.warn(
'MASTER_ENCRYPTION_KEY is not set. Using insecure fallback key. ' +
'Set MASTER_ENCRYPTION_KEY in your .env file for proper security.'
);
return Buffer.from(DEV_FALLBACK_KEY.padEnd(KEY_LENGTH, '0').slice(0, KEY_LENGTH), 'utf8');
}
// Normalize key to exactly 32 bytes
// If hex-encoded (64 chars), decode as hex; otherwise pad/slice UTF-8
if (/^[0-9a-fA-F]{64}$/.test(rawKey)) {
return Buffer.from(rawKey, 'hex');
}
return Buffer.from(rawKey.padEnd(KEY_LENGTH, '0').slice(0, KEY_LENGTH), 'utf8');
}
/**
* Encrypt a credential string using AES-256-GCM.
* Uses a unique random IV for each encryption.
*
* @param {string} plaintext - The credential to encrypt (e.g., database password)
* @returns {string} Encrypted string in format: iv:authTag:ciphertext (all hex-encoded)
*/
export function encryptCredential(plaintext) {
// Allow empty string (some DBs use root without password)
if (plaintext === null || plaintext === undefined) {
throw new Error('Cannot encrypt null or undefined value');
}
const key = getMasterKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH
});
let encrypted = cipher.update(String(plaintext), 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:ciphertext (all hex)
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
/**
* Decrypt a credential string encrypted with encryptCredential.
*
* @param {string} encryptedString - Encrypted string in format: iv:authTag:ciphertext
* @returns {string} Decrypted plaintext credential
* @throws {Error} If the format is invalid, key is wrong, or data was tampered with
*/
export function decryptCredential(encryptedString) {
if (!encryptedString || typeof encryptedString !== 'string') {
throw new Error('Invalid encrypted string: must be a non-empty string');
}
const parts = encryptedString.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted format: expected iv:authTag:ciphertext');
}
const [ivHex, authTagHex, ciphertext] = parts;
// Validate hex format
if (!/^[0-9a-fA-F]+$/.test(ivHex) || !/^[0-9a-fA-F]+$/.test(authTagHex)) {
throw new Error('Invalid encrypted format: IV and authTag must be hex-encoded');
}
const key = getMasterKey();
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH
});
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Check if a string appears to be encrypted by this module.
* Validates the iv:authTag:ciphertext format.
*
* @param {string} value - String to check
* @returns {boolean} True if the string matches the encrypted format
*/
export function isEncrypted(value) {
if (!value || typeof value !== 'string') {
return false;
}
const parts = value.split(':');
if (parts.length !== 3) {
return false;
}
const [ivHex, authTagHex, ciphertext] = parts;
// IV should be IV_LENGTH bytes = IV_LENGTH*2 hex chars
if (ivHex.length !== IV_LENGTH * 2) {
return false;
}
// Auth tag should be AUTH_TAG_LENGTH bytes = AUTH_TAG_LENGTH*2 hex chars
if (authTagHex.length !== AUTH_TAG_LENGTH * 2) {
return false;
}
// All parts should be hex
return /^[0-9a-fA-F]+$/.test(ivHex) &&
/^[0-9a-fA-F]+$/.test(authTagHex) &&
/^[0-9a-fA-F]*$/.test(ciphertext); // ciphertext can be empty for empty string
}
/**
* Remove sensitive credential fields from a data source object
* before returning it in API responses.
*
* @param {Object} dataSource - Data source object from database
* @returns {Object} Sanitized data source safe for API response
*/
export function sanitizeCredentialsForResponse(dataSource) {
if (!dataSource) return dataSource;
// Create a shallow copy to avoid mutating the original
const sanitized = { ...dataSource };
// Track if a password was set
const hasPassword = Boolean(
sanitized.db_password_encrypted ||
sanitized.db_password_key
);
// Remove sensitive fields
delete sanitized.db_password_encrypted;
delete sanitized.db_password_key;
delete sanitized.ssh_private_key;
delete sanitized.ssh_key_name;
// Add indicator for UI
sanitized.has_password = hasPassword;
return sanitized;
}