ToolJet/server/scripts/rotate-lockbox-key.ts

593 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AppModule } from '@modules/app/module';
import { NestFactory } from '@nestjs/core';
import { EntityManager } from 'typeorm';
import * as readline from 'readline';
import { DualKeyEncryptionService } from './services/rotation.service';
import { Credential } from '../src/entities/credential.entity';
import { OrgEnvironmentConstantValue } from '../src/entities/org_environment_constant_values.entity';
import { SSOConfigs } from '../src/entities/sso_config.entity';
import { OrganizationTjdbConfigurations } from '../src/entities/organization_tjdb_configurations.entity';
import { UserDetails } from '../src/entities/user_details.entity';
import { getEnvVars } from './database-config-utils';
import { dbTransactionWrap } from '../src/helpers/database.helper';
// Load environment variables from .env file
const ENV_VARS = getEnvVars();
Object.keys(ENV_VARS).forEach((key) => {
if (process.env[key] === undefined) {
process.env[key] = ENV_VARS[key];
}
});
/**
* LOCKBOX_MASTER_KEY Rotation Script
*
* This script rotates the LOCKBOX_MASTER_KEY by decrypting all encrypted data
* with the old key and re-encrypting it with a new key.
*
* Usage:
* npm run rotate:keys -- --dry-run # Test without making changes
* npm run rotate:keys # Perform actual rotation
*
* How it works:
* 1. Update LOCKBOX_MASTER_KEY in .env with your NEW key
* 2. Run this script - it will prompt you to enter the OLD key
* 3. Script decrypts with old key, re-encrypts with new key
* 4. Restart your application
*
* IMPORTANT:
* - Stop the application before running this script
* - Backup the database before running
* - Test with --dry-run first in staging
* - Keep the old key handy - you'll need to enter it when prompted
*/
class RotationProgress {
private totalTables = 5;
private completedTables = 0;
private currentTable = '';
private currentTableRows = 0;
private currentTableTotal = 0;
startTable(tableName: string, totalRows: number): void {
this.currentTable = tableName;
this.currentTableRows = 0;
this.currentTableTotal = totalRows;
console.log(`\n[${this.completedTables + 1}/${this.totalTables}] Processing ${tableName} (${totalRows} rows)...`);
}
incrementRow(): void {
this.currentTableRows++;
if (this.currentTableRows % 10 === 0 || this.currentTableRows === this.currentTableTotal) {
const percent = this.currentTableTotal > 0 ? ((this.currentTableRows / this.currentTableTotal) * 100).toFixed(1) : '0.0';
process.stdout.write(`\r Progress: ${this.currentTableRows}/${this.currentTableTotal} (${percent}%)`);
}
}
completeTable(): void {
this.completedTables++;
console.log(`\n ✓ ${this.currentTable} completed`);
}
complete(): void {
console.log(`\n✓ All ${this.totalTables} tables rotated successfully`);
}
}
async function bootstrap() {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ LOCKBOX_MASTER_KEY Rotation Script ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
// Parse command-line arguments
const isDryRun = process.argv.includes('--dry-run');
if (isDryRun) {
console.log('🔍 DRY RUN MODE: No changes will be made\n');
} else {
console.log('⚠️ PRODUCTION MODE: Changes will be committed\n');
}
try {
// Pre-flight checks
console.log('Step 1: Validating new master key...');
validateEnvironment();
console.log('✓ New master key (LOCKBOX_MASTER_KEY) validated');
// Prompt for old key
console.log('\nStep 2: Enter old master key...');
console.log(' You will be prompted to enter your OLD master key');
console.log(' (the key currently used in production)\n');
const oldKey = await promptForOldKey();
const newKey = process.env.LOCKBOX_MASTER_KEY!;
// Validate old key format
validateKeyFormat(oldKey, 'Old master key');
console.log('✓ Old key format validated');
// Test encryption keys
const dualKeyService = new DualKeyEncryptionService(oldKey, newKey);
console.log('\nStep 3: Testing encryption keys...');
await dualKeyService.testEncryptionCycle(oldKey, 'Old master key');
console.log('✓ Old key validated');
await dualKeyService.testEncryptionCycle(newKey, 'New master key (LOCKBOX_MASTER_KEY)');
console.log('✓ New key validated');
const keyInfo = dualKeyService.getKeyInfo();
if (!keyInfo.keysAreDifferent) {
throw new Error('Old and new keys are identical. Please provide different keys.');
}
console.log('✓ Keys are different');
// For dry-run, test with database and exit early without making changes
if (isDryRun) {
console.log('\nStep 4: Connecting to database...');
const nestApp = await NestFactory.createApplicationContext(await AppModule.register({ IS_GET_CONTEXT: true }), {
logger: ['error', 'warn'],
});
console.log('✓ Database connection established');
console.log('\nStep 5: Testing decryption with old key...');
const entityManager = nestApp.get(EntityManager);
await testDecryptionWithOldKey(entityManager, dualKeyService);
console.log('✓ Successfully verified old key can decrypt existing data');
await nestApp.close();
console.log('\n' + '═'.repeat(60));
console.log('✓ DRY RUN COMPLETED SUCCESSFULLY');
console.log('\n✓ Key validation passed');
console.log('✓ Old key can decrypt existing data');
console.log('✓ New key is ready to use');
console.log('\n⚠ No changes were made to the database.');
console.log(' Run without --dry-run to perform actual rotation.');
console.log('═'.repeat(60) + '\n');
process.exit(0);
}
// Production mode: Backup confirmation and actual rotation
console.log('\nStep 4: Backup confirmation...');
await promptBackupConfirmation();
// Initialize NestJS application context for database connection
console.log('\nStep 5: Connecting to database...');
const nestApp = await NestFactory.createApplicationContext(await AppModule.register({ IS_GET_CONTEXT: true }), {
logger: ['error', 'warn'],
});
console.log('✓ Database connection established');
// Start rotation
console.log('\n' + '═'.repeat(60));
console.log('Starting rotation process...');
console.log('═'.repeat(60));
const progress = new RotationProgress();
// Use dbTransactionWrap for automatic transaction management
await dbTransactionWrap(async (entityManager: EntityManager) => {
// Rotate all tables
await rotateCredentials(entityManager, dualKeyService, progress);
await rotateOrgConstants(entityManager, dualKeyService, progress);
await rotateSSOConfigs(entityManager, dualKeyService, progress);
await rotateTJDBConfigs(entityManager, dualKeyService, progress);
await rotateUserDetails(entityManager, dualKeyService, progress);
progress.complete();
// Verify rotation
console.log('\nStep 6: Verifying rotation...');
await verifyRotation(entityManager, newKey);
console.log('✓ Rotation verified successfully');
console.log('\nStep 7: Committing changes...');
});
// Cleanup
await nestApp.close();
// Success message
console.log('\n' + '═'.repeat(60));
console.log('✓ ROTATION COMPLETED SUCCESSFULLY');
console.log('\n⚠ IMPORTANT NEXT STEPS:');
console.log('1. Restart your application');
console.log(' - LOCKBOX_MASTER_KEY in .env is already set to the new key');
console.log(' - Your application will now use the new key for all encryption');
console.log('═'.repeat(60) + '\n');
process.exit(0);
} catch (error) {
console.error('\n❌ ROTATION FAILED:', error.message);
console.error('\nThe database has not been modified.');
process.exit(1);
}
}
function validateEnvironment(): void {
if (!process.env.LOCKBOX_MASTER_KEY) {
throw new Error('LOCKBOX_MASTER_KEY environment variable is not set. Please set it to your NEW master key in .env');
}
// Validate format (64 hex characters)
const hexRegex = /^[0-9a-fA-F]{64}$/;
if (!hexRegex.test(process.env.LOCKBOX_MASTER_KEY)) {
throw new Error('LOCKBOX_MASTER_KEY must be exactly 64 hexadecimal characters (0-9, a-f, A-F)');
}
}
function validateKeyFormat(key: string, label: string): void {
const hexRegex = /^[0-9a-fA-F]{64}$/;
if (!hexRegex.test(key)) {
throw new Error(`${label} must be exactly 64 hexadecimal characters (0-9, a-f, A-F)`);
}
}
async function promptForOldKey(): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve, reject) => {
rl.question(
'Please enter the old key: ',
(answer) => {
rl.close();
const key = answer.trim();
if (!key) {
reject(new Error('Old key is required'));
return;
}
try {
validateKeyFormat(key, 'Old master key');
resolve(key);
} catch (error) {
reject(error);
}
}
);
});
}
async function promptBackupConfirmation(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve, reject) => {
rl.question(
'⚠️ Have you backed up the database? This operation cannot be undone. (yes/no): ',
(answer) => {
rl.close();
if (answer.toLowerCase() === 'yes') {
console.log('✓ Backup confirmed');
resolve();
} else {
reject(new Error('Database backup not confirmed. Aborting rotation.'));
}
}
);
});
}
// Table 1: credentials
async function rotateCredentials(
entityManager: EntityManager,
dualKeyService: DualKeyEncryptionService,
progress: RotationProgress
): Promise<void> {
const credentials = await entityManager.find(Credential);
progress.startTable('credentials', credentials.length);
for (const cred of credentials) {
if (!cred.valueCiphertext) {
progress.incrementRow();
continue; // Skip nulls
}
try {
// Decrypt with old key
const plainValue = await dualKeyService.decryptWithOldKey('credentials', 'value', cred.valueCiphertext);
// Encrypt with new key
const newCiphertext = await dualKeyService.encryptWithNewKey('credentials', 'value', plainValue);
cred.valueCiphertext = newCiphertext;
await entityManager.save(cred);
progress.incrementRow();
} catch (error) {
throw new Error(`Failed to rotate credential ${cred.id}: ${error.message}`);
}
}
progress.completeTable();
}
// Table 2: org_environment_constant_values
async function rotateOrgConstants(
entityManager: EntityManager,
dualKeyService: DualKeyEncryptionService,
progress: RotationProgress
): Promise<void> {
const constants = await entityManager.find(OrgEnvironmentConstantValue, {
relations: ['organizationConstant'],
});
progress.startTable('org_environment_constant_values', constants.length);
for (const constant of constants) {
if (!constant.value) {
progress.incrementRow();
continue;
}
try {
const orgId = constant.organizationConstant.organizationId;
// Decrypt with old key (using orgId as column param)
const plainValue = await dualKeyService.decryptWithOldKey('org_environment_constant_values', orgId, constant.value);
// Encrypt with new key
const newCiphertext = await dualKeyService.encryptWithNewKey('org_environment_constant_values', orgId, plainValue);
constant.value = newCiphertext;
await entityManager.save(constant);
progress.incrementRow();
} catch (error) {
throw new Error(`Failed to rotate org constant ${constant.id}: ${error.message}`);
}
}
progress.completeTable();
}
// Table 3: sso_configs
async function rotateSSOConfigs(
entityManager: EntityManager,
dualKeyService: DualKeyEncryptionService,
progress: RotationProgress
): Promise<void> {
const ssoConfigs = await entityManager.find(SSOConfigs);
progress.startTable('sso_configs', ssoConfigs.length);
for (const config of ssoConfigs) {
if (!config.configs) {
progress.incrementRow();
continue;
}
try {
const configsObj = typeof config.configs === 'string' ? JSON.parse(config.configs) : config.configs;
let modified = false;
// Find all fields containing "secret" (case-insensitive)
for (const [key, value] of Object.entries(configsObj)) {
if (key.toLowerCase().includes('secret') && typeof value === 'string' && value.length > 0) {
try {
// Decrypt with old key
const plainValue = await dualKeyService.decryptWithOldKey('ssoConfigs', key, value);
// Encrypt with new key
const newCiphertext = await dualKeyService.encryptWithNewKey('ssoConfigs', key, plainValue);
configsObj[key] = newCiphertext;
modified = true;
} catch (error) {
// If decryption fails, the field might not be encrypted or already using new key
console.warn(` ⚠️ Could not decrypt SSO config ${config.id} field "${key}": ${error.message}`);
}
}
}
if (modified) {
config.configs = configsObj;
await entityManager.save(config);
}
progress.incrementRow();
} catch (error) {
throw new Error(`Failed to rotate SSO config ${config.id}: ${error.message}`);
}
}
progress.completeTable();
}
// Table 4: organization_tjdb_configurations
async function rotateTJDBConfigs(
entityManager: EntityManager,
dualKeyService: DualKeyEncryptionService,
progress: RotationProgress
): Promise<void> {
const configs = await entityManager.find(OrganizationTjdbConfigurations);
progress.startTable('organization_tjdb_configurations', configs.length);
for (const config of configs) {
if (!config.pgPassword) {
progress.incrementRow();
continue;
}
try {
// Decrypt with old key
const plainPassword = await dualKeyService.decryptWithOldKey(
'organization_tjdb_configurations',
'pg_password',
config.pgPassword
);
// Encrypt with new key
const newCiphertext = await dualKeyService.encryptWithNewKey(
'organization_tjdb_configurations',
'pg_password',
plainPassword
);
config.pgPassword = newCiphertext;
await entityManager.save(config);
progress.incrementRow();
} catch (error) {
throw new Error(`Failed to rotate TJDB config ${config.id}: ${error.message}`);
}
}
progress.completeTable();
}
// Table 5: user_details
async function rotateUserDetails(
entityManager: EntityManager,
dualKeyService: DualKeyEncryptionService,
progress: RotationProgress
): Promise<void> {
const userDetails = await entityManager.find(UserDetails);
progress.startTable('user_details', userDetails.length);
for (const detail of userDetails) {
if (!detail.userMetadata) {
progress.incrementRow();
continue;
}
try {
// Decrypt with old key
const plainMetadata = await dualKeyService.decryptWithOldKey('user_details', 'userMetadata', detail.userMetadata);
// Encrypt with new key
const newCiphertext = await dualKeyService.encryptWithNewKey('user_details', 'userMetadata', plainMetadata);
detail.userMetadata = newCiphertext;
await entityManager.save(detail);
progress.incrementRow();
} catch (error) {
throw new Error(`Failed to rotate user detail ${detail.id}: ${error.message}`);
}
}
progress.completeTable();
}
async function verifyRotation(entityManager: EntityManager, newKey: string): Promise<void> {
const testService = new DualKeyEncryptionService(newKey, newKey);
// Test credentials
const credential = await entityManager.findOne(Credential, { where: {} });
if (credential?.valueCiphertext) {
await testService.decryptWithOldKey('credentials', 'value', credential.valueCiphertext);
console.log(' ✓ Credentials table verified');
}
// Test org constants
const orgConstant = await entityManager.findOne(OrgEnvironmentConstantValue, {
where: {},
relations: ['organizationConstant'],
});
if (orgConstant?.value) {
const orgId = orgConstant.organizationConstant.organizationId;
await testService.decryptWithOldKey('org_environment_constant_values', orgId, orgConstant.value);
console.log(' ✓ Organization constants table verified');
}
// Test SSO configs
const ssoConfig = await entityManager.findOne(SSOConfigs, { where: {} });
if (ssoConfig?.configs) {
const configsObj = typeof ssoConfig.configs === 'string' ? JSON.parse(ssoConfig.configs) : ssoConfig.configs;
for (const [key, value] of Object.entries(configsObj)) {
if (key.toLowerCase().includes('secret') && typeof value === 'string' && value.length > 0) {
await testService.decryptWithOldKey('ssoConfigs', key, value);
break;
}
}
console.log(' ✓ SSO configs table verified');
}
// Test TJDB configs
const tjdbConfig = await entityManager.findOne(OrganizationTjdbConfigurations, { where: {} });
if (tjdbConfig?.pgPassword) {
await testService.decryptWithOldKey('organization_tjdb_configurations', 'pg_password', tjdbConfig.pgPassword);
console.log(' ✓ TJDB configurations table verified');
}
// Test user details
const userDetail = await entityManager.findOne(UserDetails, { where: {} });
if (userDetail?.userMetadata) {
await testService.decryptWithOldKey('user_details', 'userMetadata', userDetail.userMetadata);
console.log(' ✓ User details table verified');
}
}
/**
* Test decryption with old key (for dry-run validation)
* This is a read-only operation that verifies the old key can decrypt existing data
*/
async function testDecryptionWithOldKey(
entityManager: EntityManager,
dualKeyService: DualKeyEncryptionService
): Promise<void> {
let testedCount = 0;
// Test credentials
const credential = await entityManager.findOne(Credential, { where: {} });
if (credential?.valueCiphertext) {
await dualKeyService.decryptWithOldKey('credentials', 'value', credential.valueCiphertext);
console.log(' ✓ Credentials table - old key works');
testedCount++;
}
// Test org constants
const orgConstant = await entityManager.findOne(OrgEnvironmentConstantValue, {
where: {},
relations: ['organizationConstant'],
});
if (orgConstant?.value) {
const orgId = orgConstant.organizationConstant.organizationId;
await dualKeyService.decryptWithOldKey('org_environment_constant_values', orgId, orgConstant.value);
console.log(' ✓ Organization constants table - old key works');
testedCount++;
}
// Test SSO configs
const ssoConfig = await entityManager.findOne(SSOConfigs, { where: {} });
if (ssoConfig?.configs) {
const configsObj = typeof ssoConfig.configs === 'string' ? JSON.parse(ssoConfig.configs) : ssoConfig.configs;
for (const [key, value] of Object.entries(configsObj)) {
if (key.toLowerCase().includes('secret') && typeof value === 'string' && value.length > 0) {
await dualKeyService.decryptWithOldKey('ssoConfigs', key, value);
console.log(' ✓ SSO configs table - old key works');
testedCount++;
break;
}
}
}
// Test TJDB configs
const tjdbConfig = await entityManager.findOne(OrganizationTjdbConfigurations, { where: {} });
if (tjdbConfig?.pgPassword) {
await dualKeyService.decryptWithOldKey('organization_tjdb_configurations', 'pg_password', tjdbConfig.pgPassword);
console.log(' ✓ TJDB configurations table - old key works');
testedCount++;
}
// Test user details
const userDetail = await entityManager.findOne(UserDetails, { where: {} });
if (userDetail?.userMetadata) {
await dualKeyService.decryptWithOldKey('user_details', 'userMetadata', userDetail.userMetadata);
console.log(' ✓ User details table - old key works');
testedCount++;
}
if (testedCount === 0) {
console.log(' ⚠️ No encrypted data found to test (database might be empty)');
}
}
// Run the script
// eslint-disable-next-line @typescript-eslint/no-floating-promises
bootstrap();