UNPKG

@tshifhiwa/ohrm-ui-automation-framework

Version:

Playwright and TypeScript–based test automation framework for validating core UI features and workflows of the OrangeHRM demo application.

374 lines (319 loc) 13.3 kB
import crypto from "crypto"; import SecureKeyGenerator from "../../key/secureKeyGenerator.js"; import { CryptoEngine } from "../../engine/cryptoEngine.js"; import SecretMetadataManager from "./secretMetadataManager.js"; import SecretFilePathResolver from "../../../environment/manager/resolvers/secretFilePathResolver.js"; import SecretFileManager from "../../../environment/manager/handlers/secretFileManager.js"; import StagesFileManager from "../../../environment/manager/handlers/stagesFileManager.js"; import KeyExpirationCalculator from "../utils/keyExpirationCalculator.js"; import type { RotationResult } from "../types/rotation.types.js"; import type { DecryptedVariable } from "../types/decryptedVariable.types.js"; import ErrorHandler from "../../../errorHandling/errorHandler.js"; import logger from "../../../logger/loggerManager.js"; export default class SecretKeyRotationManager { /** * Validates that rotation is necessary or forced. */ public static async validateRotationNecessary(keyName: string, force: boolean): Promise<void> { if (force) { logger.info("Force rotation enabled - skipping validation"); return; } const rotationStatus = await SecretMetadataManager.checkKeyRotationStatus(keyName); if (!rotationStatus.needsRotation && rotationStatus.status === "active") { const daysRemaining = rotationStatus.daysUntilExpiration; throw new Error( `Key "${keyName}" does not need rotation yet (${daysRemaining} days remaining). ` + `Use forceRotation: true to rotate anyway.`, ); } } // ==================== KEY OPERATIONS ==================== /** * Retrieves the current/old secret key before rotation. */ public static async getOldSecretKey(keyName: string): Promise<string> { try { const secretFilePath = SecretFilePathResolver.getSecretFilePath(); const oldKey = await SecretFileManager.getKeyValue(secretFilePath, keyName); if (!oldKey) { throw new Error(`Secret key "${keyName}" not found in secret file`); } logger.debug(`Retrieved old key for "${keyName}"`); return oldKey; } catch (error) { ErrorHandler.captureError( error, "getOldSecretKey", `Failed to retrieve old key "${keyName}"`, ); throw error; } } /** * Generates a new secret key. */ public static async generateNewSecretKey(): Promise<string> { try { return SecureKeyGenerator.generateBase64SecretKey(); } catch (error) { ErrorHandler.captureError(error, "generateNewSecretKey", "Failed to generate new secret key"); throw error; } } /** * Stores the newly generated secret key. */ public static async storeNewSecretKey(keyName: string, newKey: string): Promise<void> { try { const secretFilePath = SecretFilePathResolver.getSecretFilePath(); await SecretFileManager.storeKeyInFile(secretFilePath, keyName, newKey, { skipIfExists: false, // Force overwrite }); await SecretFileManager.ensureSecretKeyExists(keyName); logger.info(`New key stored successfully for "${keyName}"`); } catch (error) { ErrorHandler.captureError(error, "storeNewSecretKey", `Failed to store new key "${keyName}"`); throw error; } } /** * Creates SHA-256 hash of a key for audit purposes. */ public static hashKey(key: string): string { try { return crypto.createHash("sha256").update(key, "utf8").digest("hex").substring(0, 16); } catch (error) { ErrorHandler.captureError(error, "hashKey", "Failed to hash key for audit purposes"); throw error; } } // ==================== DECRYPTION/ENCRYPTION ==================== /** * Decrypts all encrypted environment variables using the old key. */ public static async decryptAllEnvironmentVariables( filePath: string, keyName: string, oldKey: string, ): Promise<DecryptedVariable[]> { try { logger.info(`Decrypting variables from "${filePath}" with old key...`); const envFileLines = await StagesFileManager.readEnvironmentFileAsLines(filePath); const allVariables = StagesFileManager.extractEnvironmentVariables(envFileLines); const decryptedVariables: DecryptedVariable[] = []; const encryptedVars: string[] = []; const failedVars: string[] = []; for (const [key, value] of Object.entries(allVariables)) { const trimmedValue = value.trim(); if (!trimmedValue || !CryptoEngine.isEncrypted(trimmedValue)) { // Not encrypted - keep as is decryptedVariables.push({ key, originalValue: value, decryptedValue: value, wasEncrypted: false, }); continue; } try { encryptedVars.push(key); const decryptedValue = await this.decryptWithKey(trimmedValue, oldKey); decryptedVariables.push({ key, originalValue: value, decryptedValue, wasEncrypted: true, }); logger.debug(`✓ Decrypted: ${key}`); } catch (decryptError) { failedVars.push(key); logger.error(`Failed to decrypt "${key}": ${decryptError}`); throw new Error(`Failed to decrypt variable "${key}". Cannot proceed with rotation.`); } } logger.info( `Decryption complete: ${encryptedVars.length} encrypted variables processed, ` + `${failedVars.length} failed`, ); if (failedVars.length > 0) { throw new Error( `Failed to decrypt ${failedVars.length} variable(s): ${failedVars.join(", ")}`, ); } return decryptedVariables; } catch (error) { ErrorHandler.captureError( error, "decryptAllEnvironmentVariables", "Failed to decrypt environment variables", ); throw error; } } /** * Re-encrypts all variables with the new key and updates the file. */ public static async reEncryptAllVariables( filePath: string, decryptedVariables: DecryptedVariable[], keyName: string, newKey: string, ): Promise<{ variablesProcessed: number; variablesFailed: string[] }> { try { logger.info(`Re-encrypting variables with new key...`); const variablesToEncrypt = decryptedVariables.filter((v) => v.wasEncrypted); const encryptedVariables: Record<string, string> = {}; const failedVariables: string[] = []; for (const variable of variablesToEncrypt) { try { const encryptedValue = await this.encryptWithKey(variable.decryptedValue, newKey); encryptedVariables[variable.key] = encryptedValue; logger.debug(`Re-encrypted: ${variable.key}`); } catch (encryptError) { failedVariables.push(variable.key); logger.error(`✗ Failed to re-encrypt "${variable.key}": ${encryptError}`); } } if (failedVariables.length > 0) { throw new Error( `Failed to re-encrypt ${failedVariables.length} variable(s): ${failedVariables.join(", ")}`, ); } // Update all re-encrypted variables in the file const envFileLines = await StagesFileManager.readEnvironmentFileAsLines(filePath); const updatedLines = StagesFileManager.updateMultipleEnvironmentVariables( envFileLines, encryptedVariables, ); await StagesFileManager.writeEnvironmentFileLines(filePath, updatedLines); logger.info( `Re-encryption complete: ${Object.keys(encryptedVariables).length} variables processed`, ); return { variablesProcessed: Object.keys(encryptedVariables).length, variablesFailed: failedVariables, }; } catch (error) { ErrorHandler.captureError(error, "reEncryptAllVariables", "Failed to re-encrypt variables"); throw error; } } /** * Decrypts a value using a provided key (bypasses environment lookup). */ public static async decryptWithKey(encryptedData: string, secretKey: string): Promise<string> { try { CryptoEngine.validateSecretKey(secretKey); CryptoEngine.validateInputs(encryptedData, secretKey, "decrypt"); const { salt, iv, cipherText, receivedHmac } = CryptoEngine.parseEncryptedData(encryptedData); const { encryptionKey, hmacKey } = await CryptoEngine.deriveKeysWithArgon2(secretKey, salt); await CryptoEngine.verifyHMAC(salt, iv, cipherText, receivedHmac, hmacKey); const decryptedBuffer = await CryptoEngine.performDecryption(iv, encryptionKey, cipherText); return new TextDecoder().decode(new Uint8Array(decryptedBuffer)); } catch (error) { ErrorHandler.captureError(error, "decryptWithKey", "Failed to decrypt with provided key"); throw error; } } /** * Encrypts a value using a provided key (bypasses environment lookup). */ public static async encryptWithKey(value: string, secretKey: string): Promise<string> { try { CryptoEngine.validateSecretKey(secretKey); CryptoEngine.validateInputs(value, secretKey, "encrypt"); const { salt, iv, encryptionKey, hmacKey } = await CryptoEngine.generateEncryptionComponents(secretKey); return await CryptoEngine.createEncryptedPayload(value, salt, iv, encryptionKey, hmacKey); } catch (error) { ErrorHandler.captureError(error, "encryptWithKey", "Failed to encrypt with provided key"); throw error; } } // ==================== TRACKING & UTILITIES ==================== /** * Counts encrypted variables in the environment file. */ public static async countEncryptedVariables(filePath: string): Promise<number> { try { const envFileLines = await StagesFileManager.readEnvironmentFileAsLines(filePath); const allVariables = StagesFileManager.extractEnvironmentVariables(envFileLines); return Object.values(allVariables).filter((value) => CryptoEngine.isEncrypted(value.trim())) .length; } catch (error) { ErrorHandler.captureError( error, "countEncryptedVariables", "Failed to count encrypted variables", ); return 0; } } /** * Creates dry run result without performing actual rotation. */ public static createDryRunResult( keyName: string, environment: string, decryptedVariables: DecryptedVariable[], startTime: number, ): RotationResult { const encryptedCount = decryptedVariables.filter((v) => v.wasEncrypted).length; return { success: true, keyName, environment, variablesProcessed: encryptedCount, variablesFailed: [], duration: Date.now() - startTime, }; } // ==================== AUDIT OPERATIONS ==================== /** * Checks all tracked keys and reports their rotation status. */ public static async auditAllSecretKeys(): Promise<void> { try { logger.info("=== SECRET KEY ROTATION AUDIT ==="); const keysNeedingRotation = await SecretMetadataManager.getKeysNeedingRotation(); const keysExpiringSoon = await SecretMetadataManager.getKeysExpiringSoon(); const allKeys = await SecretMetadataManager.getAllTrackedKeys(); if (keysNeedingRotation.length > 0) { logger.warn(`\n🔴 EXPIRED KEYS (${keysNeedingRotation.length}):`); for (const key of keysNeedingRotation) { const daysExpired = Math.abs( KeyExpirationCalculator.calculateDaysUntilExpiration(key.expiresAt), ); logger.warn(` - ${key.keyName} (${key.environment}): EXPIRED ${daysExpired} days ago`); } } if (keysExpiringSoon.length > 0) { logger.info(`\n🟡 EXPIRING SOON (${keysExpiringSoon.length}):`); for (const key of keysExpiringSoon) { const daysRemaining = KeyExpirationCalculator.calculateDaysUntilExpiration(key.expiresAt); logger.info(` - ${key.keyName} (${key.environment}): ${daysRemaining} days remaining`); } } const activeKeys = allKeys.filter((k) => k.status === "active"); if (activeKeys.length > 0) { logger.info(`\n🟢 ACTIVE KEYS (${activeKeys.length}):`); for (const key of activeKeys) { const daysRemaining = KeyExpirationCalculator.calculateDaysUntilExpiration(key.expiresAt); logger.info( ` - ${key.keyName} (${key.environment}): ${daysRemaining} days remaining ` + `(Rotated ${key.rotationCount} time(s))`, ); } } if (allKeys.length === 0) { logger.info("\nNo secret keys are currently tracked."); } else { logger.info(`\nTotal tracked keys: ${allKeys.length}`); } } catch (error) { ErrorHandler.captureError(error, "auditAllSecretKeys", "Failed to audit secret keys"); throw error; } } }