Implement Enterprise-Grade Database Encryption System #244
@@ -0,0 +1,281 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../database/db/index.js';
|
||||
import { settings } from '../database/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { databaseLogger } from './logger.js';
|
||||
|
||||
interface EncryptionKeyInfo {
|
||||
hasKey: boolean;
|
||||
keyId?: string;
|
||||
createdAt?: string;
|
||||
algorithm: string;
|
||||
}
|
||||
|
||||
class EncryptionKeyManager {
|
||||
private static instance: EncryptionKeyManager;
|
||||
private currentKey: string | null = null;
|
||||
private keyInfo: EncryptionKeyInfo | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): EncryptionKeyManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new EncryptionKeyManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private encodeKey(key: string): string {
|
||||
const buffer = Buffer.from(key, 'hex');
|
||||
return Buffer.from(buffer).toString('base64');
|
||||
}
|
||||
|
||||
private decodeKey(encodedKey: string): string {
|
||||
const buffer = Buffer.from(encodedKey, 'base64');
|
||||
return buffer.toString('hex');
|
||||
}
|
||||
|
||||
async initializeKey(): Promise<string> {
|
||||
databaseLogger.info('Initializing encryption key system...', {
|
||||
operation: 'key_init'
|
||||
});
|
||||
|
||||
try {
|
||||
let existingKey = await this.getStoredKey();
|
||||
|
||||
if (existingKey) {
|
||||
databaseLogger.success('Found existing encryption key', {
|
||||
operation: 'key_init',
|
||||
hasKey: true
|
||||
});
|
||||
this.currentKey = existingKey;
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
const environmentKey = process.env.DB_ENCRYPTION_KEY;
|
||||
if (environmentKey && environmentKey !== 'default-key-change-me') {
|
||||
if (!this.validateKeyStrength(environmentKey)) {
|
||||
databaseLogger.error('Environment encryption key is too weak', undefined, {
|
||||
operation: 'key_init',
|
||||
source: 'environment',
|
||||
keyLength: environmentKey.length
|
||||
});
|
||||
throw new Error('DB_ENCRYPTION_KEY is too weak. Must be at least 32 characters with good entropy.');
|
||||
}
|
||||
|
||||
databaseLogger.info('Using encryption key from environment variable', {
|
||||
operation: 'key_init',
|
||||
source: 'environment'
|
||||
});
|
||||
|
||||
await this.storeKey(environmentKey);
|
||||
this.currentKey = environmentKey;
|
||||
return environmentKey;
|
||||
}
|
||||
|
||||
const newKey = await this.generateNewKey();
|
||||
databaseLogger.warn('Generated new encryption key - PLEASE BACKUP THIS KEY', {
|
||||
operation: 'key_init',
|
||||
generated: true,
|
||||
keyPreview: newKey.substring(0, 8) + '...'
|
||||
});
|
||||
|
|
||||
|
||||
return newKey;
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error('Failed to initialize encryption key', error, {
|
||||
operation: 'key_init_failed'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateNewKey(): Promise<string> {
|
||||
const newKey = crypto.randomBytes(32).toString('hex');
|
||||
const keyId = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
await this.storeKey(newKey, keyId);
|
||||
this.currentKey = newKey;
|
||||
|
||||
databaseLogger.success('Generated new encryption key', {
|
||||
operation: 'key_generated',
|
||||
keyId,
|
||||
keyLength: newKey.length
|
||||
});
|
||||
|
||||
return newKey;
|
||||
}
|
||||
|
||||
private async storeKey(key: string, keyId?: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const id = keyId || crypto.randomBytes(8).toString('hex');
|
||||
|
||||
const keyData = {
|
||||
key: this.encodeKey(key),
|
||||
|
⚠️ Potential issue Store updates should be atomic; wrap dual settings writes in a transaction.
Suggest using a Drizzle transaction: Also applies to: 116-169 🤖 Prompt for AI Agents_⚠️ Potential issue_
**Store updates should be atomic; wrap dual settings writes in a transaction.**
`db_encryption_key` and `encryption_key_created` are updated separately; partial failure can desync metadata.
Suggest using a Drizzle transaction:
```diff
- try {
- const existing = await db.select().from(settings)...
+ try {
+ await db.transaction(async (tx) => {
+ const existing = await tx.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
+ if (existing.length > 0) {
+ await tx.update(settings).set({ value: encodedData }).where(eq(settings.key, 'db_encryption_key'));
+ } else {
+ await tx.insert(settings).values({ key: 'db_encryption_key', value: encodedData });
+ }
+ const existingCreated = await tx.select().from(settings).where(eq(settings.key, 'encryption_key_created'));
+ if (existingCreated.length > 0) {
+ await tx.update(settings).set({ value: now }).where(eq(settings.key, 'encryption_key_created'));
+ } else {
+ await tx.insert(settings).values({ key: 'encryption_key_created', value: now });
+ }
+ });
```
Also applies to: 116-169
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
In src/backend/utils/encryption-key-manager.ts around lines 100-114 (and
likewise for 116-169) the code writes the key value and the creation metadata in
separate operations which can leave store state inconsistent on partial failure;
change the implementation to perform both writes inside a single Drizzle
transaction (use the project's Drizzle transaction/runInTransaction API), write
db_encryption_key and encryption_key_created as part of the same transaction,
commit only on success, roll back on error, and only assign this.currentKey
after the transaction commits; apply the same transactional fix to the other
update block at lines 116-169.
```
</details>
<!-- fingerprinting:phantom:medusa:chinchilla -->
<!-- This is an auto-generated comment by CodeRabbit -->
|
||||
keyId: id,
|
||||
createdAt: now,
|
||||
algorithm: 'aes-256-gcm'
|
||||
};
|
||||
|
||||
const encodedData = Buffer.from(JSON.stringify(keyData)).toString('base64');
|
||||
|
||||
try {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: encodedData })
|
||||
.where(eq(settings.key, 'db_encryption_key'));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: 'db_encryption_key',
|
||||
value: encodedData
|
||||
});
|
||||
}
|
||||
|
||||
const existingCreated = await db.select().from(settings).where(eq(settings.key, 'encryption_key_created'));
|
||||
|
||||
if (existingCreated.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: now })
|
||||
.where(eq(settings.key, 'encryption_key_created'));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: 'encryption_key_created',
|
||||
value: now
|
||||
});
|
||||
}
|
||||
|
||||
this.keyInfo = {
|
||||
hasKey: true,
|
||||
keyId: id,
|
||||
createdAt: now,
|
||||
algorithm: 'aes-256-gcm'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error('Failed to store encryption key', error, {
|
||||
operation: 'key_store_failed'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getStoredKey(): Promise<string | null> {
|
||||
try {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedData = result[0].value;
|
||||
const keyData = JSON.parse(Buffer.from(encodedData, 'base64').toString());
|
||||
|
||||
this.keyInfo = {
|
||||
hasKey: true,
|
||||
keyId: keyData.keyId,
|
||||
createdAt: keyData.createdAt,
|
||||
algorithm: keyData.algorithm
|
||||
};
|
||||
|
||||
return this.decodeKey(keyData.key);
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error('Failed to retrieve stored encryption key', error, {
|
||||
operation: 'key_retrieve_failed'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentKey(): string | null {
|
||||
return this.currentKey;
|
||||
}
|
||||
|
||||
async getKeyInfo(): Promise<EncryptionKeyInfo> {
|
||||
if (!this.keyInfo) {
|
||||
const hasKey = await this.getStoredKey() !== null;
|
||||
return {
|
||||
hasKey,
|
||||
algorithm: 'aes-256-gcm'
|
||||
};
|
||||
}
|
||||
return this.keyInfo;
|
||||
}
|
||||
|
||||
async regenerateKey(): Promise<string> {
|
||||
databaseLogger.info('Regenerating encryption key', {
|
||||
operation: 'key_regenerate'
|
||||
});
|
||||
|
||||
const oldKeyInfo = await this.getKeyInfo();
|
||||
const newKey = await this.generateNewKey();
|
||||
|
||||
databaseLogger.warn('Encryption key regenerated - ALL DATA MUST BE RE-ENCRYPTED', {
|
||||
operation: 'key_regenerated',
|
||||
|
⚠️ Potential issue Don’t auto‑generate a new key on read/parse failure; fail fast to prevent data loss.
Make Also applies to: 45-61 _⚠️ Potential issue_
**Don’t auto‑generate a new key on read/parse failure; fail fast to prevent data loss.**
`getStoredKey()` returns `null` on any error, causing `initializeKey()` to generate a fresh key—breaking decryption of existing data.
Make `getStoredKey()` return `null` only when the setting is absent; throw on parse/decrypt errors so initialization fails instead of rotating the key silently.
```diff
private async getStoredKey(): Promise<string | null> {
try {
const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key'));
if (result.length === 0) {
return null;
}
@@
- } catch (error) {
- databaseLogger.error('Failed to retrieve stored encryption key', error, {
- operation: 'key_retrieve_failed'
- });
- return null;
- }
+ } catch (error) {
+ databaseLogger.error('Failed to retrieve stored encryption key', error, {
+ operation: 'key_retrieve_failed'
+ });
+ throw error; // Fail fast; do not generate a new key.
+ }
}
```
Also applies to: 45-61
<!-- fingerprinting:phantom:medusa:chinchilla -->
<!-- This is an auto-generated comment by CodeRabbit -->
|
||||
oldKeyId: oldKeyInfo.keyId,
|
||||
newKeyId: this.keyInfo?.keyId
|
||||
});
|
||||
|
||||
return newKey;
|
||||
}
|
||||
|
||||
private validateKeyStrength(key: string): boolean {
|
||||
if (key.length < 32) return false;
|
||||
|
||||
const hasLower = /[a-z]/.test(key);
|
||||
const hasUpper = /[A-Z]/.test(key);
|
||||
const hasDigit = /\d/.test(key);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(key);
|
||||
|
||||
const entropyTest = new Set(key).size / key.length;
|
||||
|
||||
return (hasLower + hasUpper + hasDigit + hasSpecial) >= 3 && entropyTest > 0.4;
|
||||
}
|
||||
|
||||
async validateKey(key?: string): Promise<boolean> {
|
||||
const testKey = key || this.currentKey;
|
||||
if (!testKey) return false;
|
||||
|
||||
try {
|
||||
const testData = 'validation-test-' + Date.now();
|
||||
const testBuffer = Buffer.from(testKey, 'hex');
|
||||
|
||||
if (testBuffer.length !== 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', testBuffer, iv) as any;
|
||||
cipher.update(testData, 'utf8');
|
||||
cipher.final();
|
||||
cipher.getAuthTag();
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.currentKey !== null;
|
||||
}
|
||||
|
||||
async getEncryptionStatus() {
|
||||
const keyInfo = await this.getKeyInfo();
|
||||
const isValid = await this.validateKey();
|
||||
|
||||
return {
|
||||
hasKey: keyInfo.hasKey,
|
||||
keyValid: isValid,
|
||||
keyId: keyInfo.keyId,
|
||||
createdAt: keyInfo.createdAt,
|
||||
algorithm: keyInfo.algorithm,
|
||||
initialized: this.isInitialized()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { EncryptionKeyManager };
|
||||
export type { EncryptionKeyInfo };
|
||||
@@ -0,0 +1,143 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface EncryptedData {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
interface EncryptionConfig {
|
||||
algorithm: string;
|
||||
keyLength: number;
|
||||
ivLength: number;
|
||||
saltLength: number;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
class FieldEncryption {
|
||||
private static readonly CONFIG: EncryptionConfig = {
|
||||
algorithm: 'aes-256-gcm',
|
||||
keyLength: 32,
|
||||
ivLength: 16,
|
||||
saltLength: 32,
|
||||
iterations: 100000,
|
||||
};
|
||||
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: ['password_hash', 'client_secret', 'totp_secret', 'totp_backup_codes', 'oidc_identifier'],
|
||||
ssh_data: ['password', 'key', 'keyPassword'],
|
||||
ssh_credentials: ['password', 'privateKey', 'keyPassword', 'key', 'publicKey']
|
||||
};
|
||||
|
||||
static isEncrypted(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return !!(parsed.data && parsed.iv && parsed.tag);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
🛠️ Refactor suggestion isEncrypted() can misclassify valid ciphertexts (empty data). Encrypted empty strings produce empty ciphertext chunks but a valid tag/iv. Current truthiness check rejects those. Apply this diff: 📝 Committable suggestion
🤖 Prompt for AI Agents_🛠️ Refactor suggestion_
**isEncrypted() can misclassify valid ciphertexts (empty data).**
Encrypted empty strings produce empty ciphertext chunks but a valid tag/iv. Current truthiness check rejects those.
Apply this diff:
```diff
- static isEncrypted(value: string | null): boolean {
+ static isEncrypted(value: string | null): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value);
- return !!(parsed.data && parsed.iv && parsed.tag);
+ return parsed && typeof parsed === 'object'
+ && typeof parsed.iv === 'string' && parsed.iv.length > 0
+ && typeof parsed.tag === 'string' && parsed.tag.length > 0
+ && Object.prototype.hasOwnProperty.call(parsed, 'data');
} catch {
return false;
}
}
```
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
static isEncrypted(value: string | null): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object'
&& typeof parsed.iv === 'string' && parsed.iv.length > 0
&& typeof parsed.tag === 'string' && parsed.tag.length > 0
&& Object.prototype.hasOwnProperty.call(parsed, 'data');
} catch {
return false;
}
}
```
</details>
<!-- suggestion_end -->
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
In src/backend/utils/encryption.ts around lines 33 to 41, isEncrypted currently
uses truthiness checks that reject valid ciphertexts with empty data; update the
checks to parse the JSON then verify the parsed value is an object that has the
'data' property (allow empty string), and that 'iv' and 'tag' exist as non-empty
strings (e.g. use hasOwnProperty('data') plus typeof parsed.iv === 'string' &&
parsed.iv.length > 0 and typeof parsed.tag === 'string' && parsed.tag.length >
0), returning true only when those conditions hold and false on parse errors.
```
</details>
<!-- fingerprinting:phantom:medusa:chinchilla -->
<!-- This is an auto-generated comment by CodeRabbit -->
|
||||
|
||||
static deriveKey(password: string, salt: Buffer, keyType: string): Buffer {
|
||||
const masterKey = crypto.pbkdf2Sync(
|
||||
password,
|
||||
salt,
|
||||
this.CONFIG.iterations,
|
||||
this.CONFIG.keyLength,
|
||||
'sha256'
|
||||
);
|
||||
|
||||
return Buffer.from(crypto.hkdfSync(
|
||||
'sha256',
|
||||
masterKey,
|
||||
salt,
|
||||
keyType,
|
||||
this.CONFIG.keyLength
|
||||
));
|
||||
}
|
||||
|
||||
static encrypt(plaintext: string, key: Buffer): EncryptedData {
|
||||
if (!plaintext) return { data: '', iv: '', tag: '' };
|
||||
|
||||
const iv = crypto.randomBytes(this.CONFIG.ivLength);
|
||||
const cipher = crypto.createCipheriv(this.CONFIG.algorithm, key, iv) as any;
|
||||
cipher.setAAD(Buffer.from('termix-field-encryption'));
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
data: encrypted,
|
||||
iv: iv.toString('hex'),
|
||||
tag: tag.toString('hex')
|
||||
};
|
||||
}
|
||||
|
||||
static decrypt(encryptedData: EncryptedData, key: Buffer): string {
|
||||
if (!encryptedData.data) return '';
|
||||
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(this.CONFIG.algorithm, key, Buffer.from(encryptedData.iv, 'hex')) as any;
|
||||
decipher.setAAD(Buffer.from('termix-field-encryption'));
|
||||
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'));
|
||||
|
||||
let decrypted = decipher.update(encryptedData.data, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
static encryptField(value: string, fieldKey: Buffer): string {
|
||||
if (!value) return '';
|
||||
if (this.isEncrypted(value)) return value;
|
||||
|
||||
const encrypted = this.encrypt(value, fieldKey);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
static decryptField(value: string, fieldKey: Buffer): string {
|
||||
if (!value) return '';
|
||||
if (!this.isEncrypted(value)) return value;
|
||||
|
||||
try {
|
||||
const encrypted: EncryptedData = JSON.parse(value);
|
||||
return this.decrypt(encrypted, fieldKey);
|
||||
} catch (error) {
|
||||
throw new Error(`Field decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
static getFieldKey(masterPassword: string, fieldType: string): Buffer {
|
||||
const salt = crypto.createHash('sha256').update(`termix-${fieldType}`).digest();
|
||||
return this.deriveKey(masterPassword, salt, fieldType);
|
||||
}
|
||||
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const tableFields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return tableFields ? tableFields.includes(fieldName) : false;
|
||||
}
|
||||
|
||||
static generateSalt(): string {
|
||||
return crypto.randomBytes(this.CONFIG.saltLength).toString('hex');
|
||||
}
|
||||
|
||||
static validateEncryptionHealth(encryptedValue: string, key: Buffer): boolean {
|
||||
try {
|
||||
if (!this.isEncrypted(encryptedValue)) return false;
|
||||
const decrypted = this.decryptField(encryptedValue, key);
|
||||
return decrypted !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { FieldEncryption };
|
||||
export type { EncryptedData, EncryptionConfig };
|
||||
⚠️ Potential issue
Env key validation rejects strong hex keys; accept 64‑hex or derive from passphrases.
As written,
validateKeyStrengthflags a cryptographically strong hex key likeopenssl rand -hex 32as “too weak” (no specials/upper), causing env keys to be rejected. This contradicts SECURITY.md and will confuse operators.Action:
Patch (minimal, hex‑first):
If you want KDF support for non‑hex env keys, I can add PBKDF2+HKDF here to normalize to 32 bytes.
Also applies to: 250-262