Improve encryption security: expand field coverage and add key validation
- Add encryption for oidc_identifier field to protect OAuth identities - Encrypt ssh_credentials.key and publicKey fields for comprehensive coverage - Add key strength validation requiring 32+ chars with complexity rules - Prevent weak environment variable keys from compromising system - Maintain backward compatibility while closing security gaps 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
281
src/backend/utils/encryption-key-manager.ts
Normal file
281
src/backend/utils/encryption-key-manager.ts
Normal file
@@ -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),
|
||||
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',
|
||||
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 };
|
||||
143
src/backend/utils/encryption.ts
Normal file
143
src/backend/utils/encryption.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user