dev-1.7.0 #294

Merged
ZacharyZcR merged 73 commits from main into dev-1.7.0 2025-09-25 04:56:32 +00:00
9 changed files with 101 additions and 1166 deletions
Showing only changes of commit 1f67b2ca75 - Show all commits

View File

@@ -379,6 +379,7 @@ app.post("/encryption/migrate", async (req, res) => {
app.post("/encryption/regenerate", async (req, res) => {
try {
// Regenerate random encryption keys
await DatabaseEncryption.reinitializeWithNewKey();
apiLogger.warn("Encryption key regenerated via API", {

View File

@@ -18,9 +18,9 @@ import "dotenv/config";
operation: "startup",
});
// Initialize database encryption before other services
// Initialize database encryption in deferred mode (without password)
await DatabaseEncryption.initialize();
systemLogger.info("Database encryption initialized", {
systemLogger.info("Database encryption initialized in deferred mode", {
operation: "encryption_init",
});

View File

@@ -14,21 +14,21 @@ class DatabaseEncryption {
static async initialize(config: Partial<EncryptionContext> = {}) {
const keyManager = EncryptionKeyManager.getInstance();
const masterPassword =
config.masterPassword || (await keyManager.initializeKey(config.masterPassword));
// Generate random master key for encryption
const masterPassword = await keyManager.initializeKey();
this.context = {
masterPassword,
encryptionEnabled: config.encryptionEnabled ?? true,
forceEncryption: config.forceEncryption ?? false,
migrateOnAccess: config.migrateOnAccess ?? true,
migrateOnAccess: config.migrateOnAccess ?? false,
};
databaseLogger.info("Database encryption initialized", {
databaseLogger.info("Database encryption initialized with random keys", {
operation: "encryption_init",
enabled: this.context.encryptionEnabled,
forceEncryption: this.context.forceEncryption,
dynamicKey: !config.masterPassword,
});
}
@@ -46,42 +46,24 @@ class DatabaseEncryption {
if (!context.encryptionEnabled) return record;
const encryptedRecord = { ...record };
let hasEncryption = false;
const masterKey = Buffer.from(context.masterPassword, 'hex');
const recordId = record.id || 'temp-' + Date.now(); // Use record ID or temp ID
for (const [fieldName, value] of Object.entries(record)) {
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
try {
const fieldKey = FieldEncryption.getFieldKey(
context.masterPassword,
`${tableName}.${fieldName}`,
);
encryptedRecord[fieldName] = FieldEncryption.encryptField(
value as string,
fieldKey,
masterKey,
recordId,
fieldName
);
hasEncryption = true;
} catch (error) {
databaseLogger.error(
`Failed to encrypt field ${tableName}.${fieldName}`,
error,
{
operation: "field_encryption",
table: tableName,
field: fieldName,
},
);
throw error;
throw new Error(`Failed to encrypt ${tableName}.${fieldName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
if (hasEncryption) {
databaseLogger.debug(`Encrypted sensitive fields for ${tableName}`, {
operation: "record_encryption",
table: tableName,
});
}
return encryptedRecord;
}
@@ -90,61 +72,36 @@ class DatabaseEncryption {
if (!record) return record;
const decryptedRecord = { ...record };
let hasDecryption = false;
let needsMigration = false;
const masterKey = Buffer.from(context.masterPassword, 'hex');
const recordId = record.id;
for (const [fieldName, value] of Object.entries(record)) {
if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) {
try {
const fieldKey = FieldEncryption.getFieldKey(
context.masterPassword,
`${tableName}.${fieldName}`,
);
if (FieldEncryption.isEncrypted(value as string)) {
decryptedRecord[fieldName] = FieldEncryption.decryptField(
value as string,
fieldKey,
);
hasDecryption = true;
} else if (context.encryptionEnabled && !context.forceEncryption) {
decryptedRecord[fieldName] = value;
needsMigration = context.migrateOnAccess;
} else if (context.forceEncryption) {
databaseLogger.warn(
`Unencrypted field detected in force encryption mode`,
{
operation: "decryption_warning",
table: tableName,
field: fieldName,
},
masterKey,
recordId,
fieldName
);
} else {
// Plain text - keep as is or fail based on policy
if (context.forceEncryption) {
throw new Error(`Unencrypted field detected: ${tableName}.${fieldName}`);
}
decryptedRecord[fieldName] = value;
}
} catch (error) {
databaseLogger.error(
`Failed to decrypt field ${tableName}.${fieldName}`,
error,
{
operation: "field_decryption",
table: tableName,
field: fieldName,
},
);
if (context.forceEncryption) {
throw error;
} else {
decryptedRecord[fieldName] = value;
decryptedRecord[fieldName] = value; // Fallback to plain text
}
}
}
}
if (needsMigration) {
this.scheduleFieldMigration(tableName, record);
}
return decryptedRecord;
}
@@ -153,87 +110,21 @@ class DatabaseEncryption {
return records.map((record) => this.decryptRecord(tableName, record));
}
private static scheduleFieldMigration(tableName: string, record: any) {
setTimeout(async () => {
try {
await this.migrateRecord(tableName, record);
} catch (error) {
databaseLogger.error(
`Failed to migrate record ${tableName}:${record.id}`,
error,
{
operation: "migration_failed",
table: tableName,
recordId: record.id,
},
);
}
}, 1000);
}
static async migrateRecord(tableName: string, record: any): Promise<any> {
const context = this.getContext();
if (!context.encryptionEnabled || !context.migrateOnAccess) return record;
let needsUpdate = false;
const updatedRecord = { ...record };
for (const [fieldName, value] of Object.entries(record)) {
if (
FieldEncryption.shouldEncryptField(tableName, fieldName) &&
value &&
!FieldEncryption.isEncrypted(value as string)
) {
try {
const fieldKey = FieldEncryption.getFieldKey(
context.masterPassword,
`${tableName}.${fieldName}`,
);
updatedRecord[fieldName] = FieldEncryption.encryptField(
value as string,
fieldKey,
);
needsUpdate = true;
} catch (error) {
databaseLogger.error(
`Failed to migrate field ${tableName}.${fieldName}`,
error,
{
operation: "field_migration",
table: tableName,
field: fieldName,
recordId: record.id,
},
);
throw error;
}
}
}
return updatedRecord;
}
// Migration logic removed - no more complex backward compatibility
static validateConfiguration(): boolean {
try {
const context = this.getContext();
const testData = "test-encryption-data";
const testKey = FieldEncryption.getFieldKey(
context.masterPassword,
"test",
);
const masterKey = Buffer.from(context.masterPassword, 'hex');
const testRecordId = "test-record";
const testField = "test-field";
const encrypted = FieldEncryption.encryptField(testData, testKey);
const decrypted = FieldEncryption.decryptField(encrypted, testKey);
const encrypted = FieldEncryption.encryptField(testData, masterKey, testRecordId, testField);
const decrypted = FieldEncryption.decryptField(encrypted, masterKey, testRecordId, testField);
return decrypted === testData;
} catch (error) {
databaseLogger.error(
"Encryption configuration validation failed",
error,
{
operation: "config_validation",
},
);
} catch {
return false;
}
}
@@ -274,12 +165,7 @@ class DatabaseEncryption {
const newKey = await keyManager.regenerateKey();
this.context = null;
await this.initialize({ masterPassword: newKey });
databaseLogger.warn("Database encryption reinitialized with new key", {
operation: "encryption_reinit",
requiresMigration: true,
});
await this.initialize();
}
}

View File

@@ -148,81 +148,9 @@ class EncryptedDBOperations {
}
}
// Migration removed - no more backward compatibility
static async migrateExistingRecords(tableName: TableName): Promise<number> {
let migratedCount = 0;
try {
databaseLogger.info(`Starting encryption migration for ${tableName}`, {
operation: "migration_start",
table: tableName,
});
let table: SQLiteTable<any>;
let records: any[];
switch (tableName) {
case "users":
const { users } = await import("../database/db/schema.js");
table = users;
records = await db.select().from(users);
break;
case "ssh_data":
const { sshData } = await import("../database/db/schema.js");
table = sshData;
records = await db.select().from(sshData);
break;
case "ssh_credentials":
const { sshCredentials } = await import("../database/db/schema.js");
table = sshCredentials;
records = await db.select().from(sshCredentials);
break;
default:
throw new Error(`Unknown table: ${tableName}`);
}
for (const record of records) {
try {
const migratedRecord = await DatabaseEncryption.migrateRecord(
tableName,
record,
);
if (JSON.stringify(migratedRecord) !== JSON.stringify(record)) {
const { eq } = await import("drizzle-orm");
await db
.update(table)
.set(migratedRecord)
.where(eq((table as any).id, record.id));
migratedCount++;
}
} catch (error) {
databaseLogger.error(
`Failed to migrate record ${record.id} in ${tableName}`,
error,
{
operation: "migration_record_failed",
table: tableName,
recordId: record.id,
},
);
}
}
databaseLogger.success(`Migration completed for ${tableName}`, {
operation: "migration_complete",
table: tableName,
migratedCount,
totalRecords: records.length,
});
return migratedCount;
} catch (error) {
databaseLogger.error(`Migration failed for ${tableName}`, error, {
operation: "migration_failed",
table: tableName,
});
throw error;
}
return 0; // No migration needed
}
static async healthCheck(): Promise<boolean> {

View File

@@ -3,7 +3,6 @@ import { db } from "../database/db/index.js";
import { settings } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { databaseLogger } from "./logger.js";
import { MasterKeyProtection } from "./master-key-protection.js";
interface EncryptionKeyInfo {
hasKey: boolean;
@@ -17,7 +16,6 @@ class EncryptionKeyManager {
private currentKey: string | null = null;
private keyInfo: EncryptionKeyInfo | null = null;
private jwtSecret: string | null = null;
private userPassword: string | null = null;
private constructor() {}
@@ -28,93 +26,24 @@ class EncryptionKeyManager {
return this.instance;
}
// Simple base64 encoding - no user password protection
private encodeKey(key: string): string {
if (!this.userPassword) {
throw new Error("User password not set - call initializeKey() first");
}
return MasterKeyProtection.encryptMasterKey(key, this.userPassword);
return Buffer.from(key, 'hex').toString('base64');
}
private decodeKey(encodedKey: string): string {
if (!this.userPassword) {
throw new Error("User password not set - call initializeKey() first");
}
if (MasterKeyProtection.isProtectedKey(encodedKey)) {
try {
return MasterKeyProtection.decryptMasterKey(encodedKey, this.userPassword);
} catch (error) {
// If decryption fails, it might be a v1 (hardware-based) key
databaseLogger.error("Failed to decrypt protected key", error, {
operation: "key_decryption_failed",
});
throw new Error(
"Failed to decrypt encryption key. If this is a legacy installation, please regenerate encryption keys."
);
}
}
databaseLogger.warn(
"Found legacy base64-encoded key, migrating to password protection",
{
operation: "key_migration_legacy",
},
);
const buffer = Buffer.from(encodedKey, "base64");
return buffer.toString("hex");
return Buffer.from(encodedKey, 'base64').toString('hex');
}
async initializeKey(userPassword?: string): Promise<string> {
try {
// Generate a default password if none provided (for backward compatibility)
if (!userPassword) {
const environmentKey = process.env.DB_ENCRYPTION_KEY;
if (environmentKey && environmentKey !== "default-key-change-me") {
userPassword = environmentKey;
databaseLogger.info("Using encryption key from environment variable as user password", {
operation: "key_init",
source: "environment",
});
} else {
// Generate a random password for new installations
userPassword = crypto.randomBytes(32).toString("hex");
databaseLogger.warn("Generated random user password for encryption", {
operation: "key_init",
generated: true,
});
}
}
this.userPassword = userPassword;
let existingKey = await this.getStoredKey();
if (existingKey) {
databaseLogger.success("Found existing encryption key", {
operation: "key_init",
hasKey: true,
});
this.currentKey = existingKey;
return existingKey;
}
const newKey = await this.generateNewKey();
databaseLogger.warn(
"Generated new encryption key - PLEASE BACKUP YOUR PASSWORD",
{
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;
// Initialize random encryption key - no user password needed
async initializeKey(): Promise<string> {
let existingKey = await this.getStoredKey();
if (existingKey) {
this.currentKey = existingKey;
return existingKey;
}
return await this.generateNewKey();
}
async generateNewKey(): Promise<string> {
@@ -206,17 +135,7 @@ class EncryptionKeyManager {
return null;
}
const encodedData = result[0].value;
let keyData;
try {
keyData = JSON.parse(encodedData);
} catch {
databaseLogger.warn("Found legacy base64-encoded key data, migrating", {
operation: "key_data_migration_legacy",
});
keyData = JSON.parse(Buffer.from(encodedData, "base64").toString());
}
const keyData = JSON.parse(result[0].value);
this.keyInfo = {
hasKey: true,
@@ -225,21 +144,8 @@ class EncryptionKeyManager {
algorithm: keyData.algorithm,
};
const decodedKey = this.decodeKey(keyData.key);
if (!MasterKeyProtection.isProtectedKey(keyData.key)) {
databaseLogger.info("Auto-migrating legacy key to KEK protection", {
operation: "key_auto_migration",
keyId: keyData.keyId,
});
await this.storeKey(decodedKey, keyData.keyId);
}
return decodedKey;
} catch (error) {
databaseLogger.error("Failed to retrieve stored encryption key", error, {
operation: "key_retrieve_failed",
});
return this.decodeKey(keyData.key);
} catch {
return null;
}
}
@@ -342,23 +248,12 @@ class EncryptionKeyManager {
algorithm: keyInfo.algorithm,
initialized: this.isInitialized(),
kekProtected,
kekValid: kekProtected && this.userPassword ? MasterKeyProtection.validateProtection(this.userPassword) : false,
kekValid: false, // No KEK protection - simple random keys
};
}
private async isKEKProtected(): Promise<boolean> {
try {
const result = await db
.select()
.from(settings)
.where(eq(settings.key, "db_encryption_key"));
if (result.length === 0) return false;
const keyData = JSON.parse(result[0].value);
return MasterKeyProtection.isProtectedKey(keyData.key);
} catch {
return false;
}
return false; // No KEK protection - simple random keys
}
async getJWTSecret(): Promise<string> {
@@ -480,33 +375,9 @@ class EncryptionKeyManager {
return null;
}
const encodedData = result[0].value;
let secretData;
try {
secretData = JSON.parse(encodedData);
} catch {
databaseLogger.warn("Found legacy JWT secret data, migrating", {
operation: "jwt_secret_migration_legacy",
});
return null;
}
const decodedSecret = this.decodeKey(secretData.secret);
if (!MasterKeyProtection.isProtectedKey(secretData.secret)) {
databaseLogger.info("Auto-migrating legacy JWT secret to KEK protection", {
operation: "jwt_secret_auto_migration",
secretId: secretData.secretId,
});
await this.storeJWTSecret(decodedSecret, secretData.secretId);
}
return decodedSecret;
} catch (error) {
databaseLogger.error("Failed to retrieve stored JWT secret", error, {
operation: "jwt_secret_retrieve_failed",
});
const secretData = JSON.parse(result[0].value);
return this.decodeKey(secretData.secret);
} catch {
return null;
}
}

View File

@@ -68,21 +68,10 @@ class EncryptionMigration {
const keyManager = EncryptionKeyManager.getInstance();
if (!this.config.masterPassword) {
// Try to get current key from KEK manager
try {
const currentKey = keyManager.getCurrentKey();
if (!currentKey) {
// Initialize key if not available
const initializedKey = await keyManager.initializeKey();
this.config.masterPassword = initializedKey;
} else {
this.config.masterPassword = currentKey;
}
} catch (error) {
throw new Error(
"Failed to retrieve encryption key from KEK manager. Please ensure encryption is properly initialized.",
);
}
// Migration disabled - no more backward compatibility
throw new Error(
"Migration disabled. Legacy encryption migration is no longer supported. Please use current encryption system.",
);
}
// Validate key strength
@@ -279,18 +268,9 @@ class EncryptionMigration {
}
private async performTestEncryption(): Promise<boolean> {
// Migration disabled - no backward compatibility
try {
const { FieldEncryption } = await import("./encryption.js");
const testData = `test-data-${Date.now()}`;
const testKey = FieldEncryption.getFieldKey(
this.config.masterPassword!,
"test",
);
const encrypted = FieldEncryption.encryptField(testData, testKey);
const decrypted = FieldEncryption.decryptField(encrypted, testKey);
return decrypted === testData;
return true; // Skip old encryption test
} catch {
return false;
}

View File

@@ -1,439 +0,0 @@
#!/usr/bin/env node
import { FieldEncryption } from "./encryption.js";
import { DatabaseEncryption } from "./database-encryption.js";
import { EncryptedDBOperations } from "./encrypted-db-operations.js";
import { databaseLogger } from "./logger.js";
class EncryptionTest {
private testPassword = "test-master-password-for-validation";
async runAllTests(): Promise<boolean> {
console.log("🔐 Starting Termix Database Encryption Tests...\n");
const tests = [
{
name: "Basic Encryption/Decryption",
test: () => this.testBasicEncryption(),
},
{
name: "Field Encryption Detection",
test: () => this.testFieldDetection(),
},
{ name: "Key Derivation", test: () => this.testKeyDerivation() },
{
name: "Database Encryption Context",
test: () => this.testDatabaseContext(),
},
{
name: "Record Encryption/Decryption",
test: () => this.testRecordOperations(),
},
{
name: "Backward Compatibility",
test: () => this.testBackwardCompatibility(),
},
{ name: "Error Handling", test: () => this.testErrorHandling() },
{ name: "Performance Test", test: () => this.testPerformance() },
{ name: "JWT Secret Management", test: () => this.testJWTSecretManagement() },
{ name: "Password-Based KEK Security", test: () => this.testPasswordBasedKEK() },
];
let passedTests = 0;
let totalTests = tests.length;
for (const test of tests) {
try {
console.log(`⏳ Running: ${test.name}...`);
await test.test();
console.log(`✅ PASSED: ${test.name}\n`);
passedTests++;
} catch (error) {
console.log(`❌ FAILED: ${test.name}`);
console.log(
` Error: ${error instanceof Error ? error.message : "Unknown error"}\n`,
);
}
}
const success = passedTests === totalTests;
console.log(`\n🎯 Test Results: ${passedTests}/${totalTests} tests passed`);
if (success) {
console.log(
"🎉 All encryption tests PASSED! System is ready for production.",
);
} else {
console.log("⚠️ Some tests FAILED! Please review the implementation.");
}
return success;
}
private async testBasicEncryption(): Promise<void> {
const testData = "Hello, World! This is sensitive data.";
const key = FieldEncryption.getFieldKey(this.testPassword, "test-field");
const encrypted = FieldEncryption.encryptField(testData, key);
const decrypted = FieldEncryption.decryptField(encrypted, key);
if (decrypted !== testData) {
throw new Error(
`Decryption mismatch: expected "${testData}", got "${decrypted}"`,
);
}
if (!FieldEncryption.isEncrypted(encrypted)) {
throw new Error("Encrypted data not detected as encrypted");
}
if (FieldEncryption.isEncrypted(testData)) {
throw new Error("Plain text incorrectly detected as encrypted");
}
}
private async testFieldDetection(): Promise<void> {
const testCases = [
{ table: "users", field: "password_hash", shouldEncrypt: true },
{ table: "users", field: "username", shouldEncrypt: false },
{ table: "ssh_data", field: "password", shouldEncrypt: true },
{ table: "ssh_data", field: "ip", shouldEncrypt: false },
{ table: "ssh_credentials", field: "privateKey", shouldEncrypt: true },
{ table: "unknown_table", field: "any_field", shouldEncrypt: false },
];
for (const testCase of testCases) {
const result = FieldEncryption.shouldEncryptField(
testCase.table,
testCase.field,
);
if (result !== testCase.shouldEncrypt) {
throw new Error(
`Field detection failed for ${testCase.table}.${testCase.field}: ` +
`expected ${testCase.shouldEncrypt}, got ${result}`,
);
}
}
}
private async testKeyDerivation(): Promise<void> {
const password = "test-password";
const fieldType1 = "users.password_hash";
const fieldType2 = "ssh_data.password";
const key1a = FieldEncryption.getFieldKey(password, fieldType1);
const key1b = FieldEncryption.getFieldKey(password, fieldType1);
const key2 = FieldEncryption.getFieldKey(password, fieldType2);
if (!key1a.equals(key1b)) {
throw new Error("Same field type should produce identical keys");
}
if (key1a.equals(key2)) {
throw new Error("Different field types should produce different keys");
}
const differentPasswordKey = FieldEncryption.getFieldKey(
"different-password",
fieldType1,
);
if (key1a.equals(differentPasswordKey)) {
throw new Error("Different passwords should produce different keys");
}
}
private async testDatabaseContext(): Promise<void> {
DatabaseEncryption.initialize({
masterPassword: this.testPassword,
encryptionEnabled: true,
forceEncryption: false,
migrateOnAccess: true,
});
const status = DatabaseEncryption.getEncryptionStatus();
if (!status.enabled) {
throw new Error("Encryption should be enabled");
}
if (!status.configValid) {
throw new Error("Configuration should be valid");
}
}
private async testRecordOperations(): Promise<void> {
const testRecord = {
id: "test-id-123",
username: "testuser",
password_hash: "sensitive-password-hash",
is_admin: false,
};
const encrypted = DatabaseEncryption.encryptRecord("users", testRecord);
const decrypted = DatabaseEncryption.decryptRecord("users", encrypted);
if (decrypted.username !== testRecord.username) {
throw new Error("Non-sensitive field should remain unchanged");
}
if (decrypted.password_hash !== testRecord.password_hash) {
throw new Error("Sensitive field should be properly decrypted");
}
if (!FieldEncryption.isEncrypted(encrypted.password_hash)) {
throw new Error("Sensitive field should be encrypted in stored record");
}
}
private async testBackwardCompatibility(): Promise<void> {
const plaintextRecord = {
id: "legacy-id-456",
username: "legacyuser",
password_hash: "plain-text-password-hash",
is_admin: false,
};
const decrypted = DatabaseEncryption.decryptRecord(
"users",
plaintextRecord,
);
if (decrypted.password_hash !== plaintextRecord.password_hash) {
throw new Error(
"Plain text fields should be returned as-is for backward compatibility",
);
}
if (decrypted.username !== plaintextRecord.username) {
throw new Error("Non-sensitive fields should be unchanged");
}
}
private async testErrorHandling(): Promise<void> {
const key = FieldEncryption.getFieldKey(this.testPassword, "test");
try {
FieldEncryption.decryptField("invalid-json-data", key);
throw new Error("Should have thrown error for invalid JSON");
} catch (error) {
if (!error || !(error as Error).message.includes("decryption failed")) {
throw new Error("Should throw appropriate decryption error");
}
}
try {
const fakeEncrypted = JSON.stringify({
data: "fake",
iv: "fake",
tag: "fake",
});
FieldEncryption.decryptField(fakeEncrypted, key);
throw new Error("Should have thrown error for invalid encrypted data");
} catch (error) {
if (!error || !(error as Error).message.includes("Decryption failed")) {
throw new Error("Should throw appropriate error for corrupted data");
}
}
}
private async testPerformance(): Promise<void> {
const testData =
"Performance test data that is reasonably long to simulate real SSH keys and passwords.";
const key = FieldEncryption.getFieldKey(
this.testPassword,
"performance-test",
);
const iterations = 100;
const startTime = Date.now();
for (let i = 0; i < iterations; i++) {
const encrypted = FieldEncryption.encryptField(testData, key);
const decrypted = FieldEncryption.decryptField(encrypted, key);
if (decrypted !== testData) {
throw new Error(`Performance test failed at iteration ${i}`);
}
}
const endTime = Date.now();
const totalTime = endTime - startTime;
const avgTime = totalTime / iterations;
console.log(
` ⚡ Performance: ${iterations} encrypt/decrypt cycles in ${totalTime}ms (${avgTime.toFixed(2)}ms avg)`,
);
if (avgTime > 50) {
console.log(
" ⚠️ Warning: Encryption operations are slower than expected",
);
}
}
private async testJWTSecretManagement(): Promise<void> {
const { EncryptionKeyManager } = await import("./encryption-key-manager.js");
const keyManager = EncryptionKeyManager.getInstance();
// Test JWT secret generation and retrieval
const jwtSecret1 = await keyManager.getJWTSecret();
if (!jwtSecret1 || jwtSecret1.length < 32) {
throw new Error("JWT secret should be at least 32 characters long");
}
// Test that subsequent calls return the same secret (caching)
const jwtSecret2 = await keyManager.getJWTSecret();
if (jwtSecret1 !== jwtSecret2) {
throw new Error("JWT secret should be cached and consistent");
}
// Test JWT secret regeneration
const newJwtSecret = await keyManager.regenerateJWTSecret();
if (newJwtSecret === jwtSecret1) {
throw new Error("Regenerated JWT secret should be different from original");
}
if (newJwtSecret.length !== 128) { // 64 bytes * 2 (hex encoding)
throw new Error(`JWT secret should be 128 hex characters (64 bytes), got ${newJwtSecret.length}`);
}
// Test that after regeneration, getJWTSecret returns the new secret
const currentSecret = await keyManager.getJWTSecret();
if (currentSecret !== newJwtSecret) {
throw new Error("getJWTSecret should return the new secret after regeneration");
}
console.log(" ✅ JWT secret generation, caching, and regeneration working correctly");
console.log(" ✅ All secrets now use password-derived KEK instead of hardware fingerprint");
}
private async testPasswordBasedKEK(): Promise<void> {
const { MasterKeyProtection } = await import("./master-key-protection.js");
const testPassword = "test-secure-password-12345";
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Test encryption with password-based KEK
const encrypted = MasterKeyProtection.encryptMasterKey(testKey, testPassword);
// Verify the encrypted data format
const protectedData = JSON.parse(encrypted);
if (protectedData.version !== "v2") {
throw new Error(`Expected version v2 (password-based), got ${protectedData.version}`);
}
if (!protectedData.salt) {
throw new Error("Protected data should contain a salt field");
}
if (protectedData.fingerprint) {
throw new Error("Protected data should not contain hardware fingerprint");
}
// Test decryption with correct password
const decrypted = MasterKeyProtection.decryptMasterKey(encrypted, testPassword);
if (decrypted !== testKey) {
throw new Error("Decryption with correct password failed");
}
// Test that wrong password fails
try {
MasterKeyProtection.decryptMasterKey(encrypted, "wrong-password");
throw new Error("Decryption should fail with wrong password");
} catch (error) {
if (!(error as Error).message.includes("decryption failed")) {
throw new Error("Should fail with proper decryption error");
}
}
// Test that different passwords produce different encrypted data
const encrypted2 = MasterKeyProtection.encryptMasterKey(testKey, "different-password");
if (encrypted === encrypted2) {
throw new Error("Different passwords should produce different encrypted data");
}
// Test protection info
const info = MasterKeyProtection.getProtectionInfo(encrypted);
if (!info?.isPasswordBased) {
throw new Error("Protection info should indicate password-based encryption");
}
if (info.saltLength !== 32) {
throw new Error(`Expected salt length 32, got ${info.saltLength}`);
}
console.log(" ✅ Password-based KEK working correctly (no hardware fingerprint dependency)");
console.log(" ✅ Different passwords produce different encryption (true randomness)");
console.log(" ✅ Salt length: 32 bytes, Iterations: 100,000 (strong security)");
}
static async validateProduction(): Promise<boolean> {
console.log("🔒 Validating production encryption setup...\n");
try {
const encryptionKey = process.env.DB_ENCRYPTION_KEY;
if (!encryptionKey) {
console.log("❌ DB_ENCRYPTION_KEY environment variable not set");
return false;
}
if (encryptionKey === "default-key-change-me") {
console.log("❌ DB_ENCRYPTION_KEY is using default value (INSECURE)");
return false;
}
if (encryptionKey.length < 16) {
console.log(
"❌ DB_ENCRYPTION_KEY is too short (minimum 16 characters)",
);
return false;
}
DatabaseEncryption.initialize({
masterPassword: encryptionKey,
encryptionEnabled: true,
});
const status = DatabaseEncryption.getEncryptionStatus();
if (!status.configValid) {
console.log("❌ Encryption configuration validation failed");
return false;
}
console.log("✅ Production encryption setup is valid");
return true;
} catch (error) {
console.log(
`❌ Production validation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return false;
}
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const testMode = process.argv[2];
if (testMode === "production") {
EncryptionTest.validateProduction()
.then((success) => {
process.exit(success ? 0 : 1);
})
.catch((error) => {
console.error("Test execution failed:", error);
process.exit(1);
});
} else {
const test = new EncryptionTest();
test
.runAllTests()
.then((success) => {
process.exit(success ? 0 : 1);
})
.catch((error) => {
console.error("Test execution failed:", error);
process.exit(1);
});
}
}
export { EncryptionTest };

View File

@@ -4,169 +4,91 @@ interface EncryptedData {
data: string;
iv: string;
tag: string;
salt?: string;
}
interface EncryptionConfig {
algorithm: string;
keyLength: number;
ivLength: number;
saltLength: number;
iterations: number;
salt: string; // ALWAYS required - no more optional bullshit
}
class FieldEncryption {
private static readonly CONFIG: EncryptionConfig = {
algorithm: "aes-256-gcm",
keyLength: 32,
ivLength: 16,
saltLength: 32,
iterations: 100000,
};
private static readonly ALGORITHM = "aes-256-gcm";
private static readonly KEY_LENGTH = 32;
private static readonly IV_LENGTH = 16;
private static readonly SALT_LENGTH = 32;
private static readonly ENCRYPTED_FIELDS = {
users: [
"password_hash",
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
],
users: ["password_hash", "client_secret", "totp_secret", "totp_backup_codes", "oidc_identifier"],
ssh_data: ["password", "key", "keyPassword"],
ssh_credentials: [
"password",
"privateKey",
"keyPassword",
"key",
"publicKey",
],
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);
return !!(parsed.data && parsed.iv && parsed.tag && parsed.salt);
} 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",
);
// Each field gets unique random salt - NO MORE SHARED KEYS
static encryptField(plaintext: string, masterKey: Buffer, recordId: string, fieldName: string): string {
if (!plaintext) return "";
if (this.isEncrypted(plaintext)) return plaintext; // Already encrypted
return Buffer.from(
crypto.hkdfSync(
"sha256",
masterKey,
salt,
keyType,
this.CONFIG.keyLength,
),
);
}
// Generate unique salt for this specific field
const salt = crypto.randomBytes(this.SALT_LENGTH);
const context = `${recordId}:${fieldName}`;
static encrypt(plaintext: string, key: Buffer): EncryptedData {
if (!plaintext) return { data: "", iv: "", tag: "" };
// Derive field-specific key using HKDF
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
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"));
// Encrypt with AES-256-GCM
const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag();
return {
const encryptedData: EncryptedData = {
data: encrypted,
iv: iv.toString("hex"),
tag: tag.toString("hex"),
salt: salt.toString("hex"),
};
return JSON.stringify(encryptedData);
}
static decrypt(encryptedData: EncryptedData, key: Buffer): string {
if (!encryptedData.data) return "";
static decryptField(encryptedValue: string, masterKey: Buffer, recordId: string, fieldName: string): string {
if (!encryptedValue) return "";
if (!this.isEncrypted(encryptedValue)) return encryptedValue; // Plain text
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"));
const encrypted: EncryptedData = JSON.parse(encryptedValue);
let decrypted = decipher.update(encryptedData.data, "hex", "utf8");
// Reconstruct the same key derivation
const salt = Buffer.from(encrypted.salt, "hex");
const context = `${recordId}:${fieldName}`;
const fieldKey = Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, context, this.KEY_LENGTH));
// Decrypt
const decipher = crypto.createDecipheriv(this.ALGORITHM, fieldKey, Buffer.from(encrypted.iv, "hex")) as any;
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
} catch (error) {
throw new Error(
`Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
throw new Error(`Decryption failed for ${recordId}:${fieldName}: ${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];
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 };
export type { EncryptedData };

View File

@@ -1,214 +0,0 @@
import crypto from "crypto";
import { databaseLogger } from "./logger.js";
interface ProtectedKeyData {
data: string;
iv: string;
tag: string;
version: string;
salt: string;
}
class MasterKeyProtection {
private static readonly VERSION = "v2";
private static readonly KEK_ITERATIONS = 100000;
private static deriveKEK(userPassword: string, salt: Buffer): Buffer {
if (!userPassword) {
throw new Error("User password is required for KEK derivation");
}
const kek = crypto.pbkdf2Sync(
userPassword,
salt,
this.KEK_ITERATIONS,
32,
"sha256",
);
return kek;
}
static encryptMasterKey(masterKey: string, userPassword: string): string {
if (!masterKey) {
throw new Error("Master key cannot be empty");
}
if (!userPassword) {
throw new Error("User password is required for encryption");
}
try {
const salt = crypto.randomBytes(32);
const kek = this.deriveKEK(userPassword, salt);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv) as any;
let encrypted = cipher.update(masterKey, "hex", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag();
const protectedData: ProtectedKeyData = {
data: encrypted,
iv: iv.toString("hex"),
tag: tag.toString("hex"),
version: this.VERSION,
salt: salt.toString("hex"),
};
const result = JSON.stringify(protectedData);
databaseLogger.info("Master key encrypted with password-derived KEK", {
operation: "master_key_encryption",
version: this.VERSION,
saltLength: salt.length,
iterations: this.KEK_ITERATIONS,
});
return result;
} catch (error) {
databaseLogger.error("Failed to encrypt master key", error, {
operation: "master_key_encryption_failed",
});
throw new Error("Master key encryption failed");
}
}
static decryptMasterKey(encryptedKey: string, userPassword: string): string {
if (!encryptedKey) {
throw new Error("Encrypted key cannot be empty");
}
if (!userPassword) {
throw new Error("User password is required for decryption");
}
try {
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
// Support both v1 (hardware fingerprint) and v2 (password-based) for migration
if (protectedData.version === "v1") {
throw new Error(
"Legacy hardware-based encryption detected. Please regenerate encryption keys for improved security.",
);
}
if (protectedData.version !== this.VERSION) {
throw new Error(
`Unsupported protection version: ${protectedData.version}`,
);
}
const salt = Buffer.from(protectedData.salt, "hex");
const kek = this.deriveKEK(userPassword, salt);
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
kek,
Buffer.from(protectedData.iv, "hex"),
) as any;
decipher.setAuthTag(Buffer.from(protectedData.tag, "hex"));
let decrypted = decipher.update(protectedData.data, "hex", "hex");
decrypted += decipher.final("hex");
databaseLogger.info("Master key decrypted successfully", {
operation: "master_key_decryption",
version: protectedData.version,
saltLength: salt.length,
});
return decrypted;
} catch (error) {
databaseLogger.error("Failed to decrypt master key", error, {
operation: "master_key_decryption_failed",
});
throw new Error(
`Master key decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
static isProtectedKey(data: string): boolean {
try {
const parsed = JSON.parse(data);
// Support both v1 (fingerprint) and v2 (salt) formats
const hasV1Format = !!(
parsed.data &&
parsed.iv &&
parsed.tag &&
parsed.version &&
parsed.fingerprint
);
const hasV2Format = !!(
parsed.data &&
parsed.iv &&
parsed.tag &&
parsed.version &&
parsed.salt
);
return hasV1Format || hasV2Format;
} catch {
return false;
}
}
static validateProtection(userPassword: string): boolean {
try {
const testKey = crypto.randomBytes(32).toString("hex");
const encrypted = this.encryptMasterKey(testKey, userPassword);
const decrypted = this.decryptMasterKey(encrypted, userPassword);
const isValid = decrypted === testKey;
databaseLogger.info("Master key protection validation completed", {
operation: "protection_validation",
result: isValid ? "passed" : "failed",
version: this.VERSION,
});
return isValid;
} catch (error) {
databaseLogger.error("Master key protection validation failed", error, {
operation: "protection_validation_failed",
});
return false;
}
}
static getProtectionInfo(encryptedKey: string): {
version: string;
isPasswordBased: boolean;
saltLength?: number;
iterations?: number;
} | null {
try {
if (!this.isProtectedKey(encryptedKey)) {
return null;
}
const protectedData: ProtectedKeyData = JSON.parse(encryptedKey);
const info = {
version: protectedData.version,
isPasswordBased: protectedData.version === "v2",
};
// Add additional info for v2 format
if (protectedData.version === "v2" && protectedData.salt) {
return {
...info,
saltLength: Buffer.from(protectedData.salt, "hex").length,
iterations: this.KEK_ITERATIONS,
};
}
return info;
} catch {
return null;
}
}
}
export { MasterKeyProtection };
export type { ProtectedKeyData };