phosphat-report-app/app/utils/encryption.server.ts
2025-08-01 05:51:08 +03:00

255 lines
7.4 KiB
TypeScript

import crypto from 'crypto';
// Get encryption key from environment or generate a default one
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'phosphat-report-default-key-32b'; // Must be 32 characters
const ALGORITHM = 'aes-256-cbc';
const IV_LENGTH = 16; // For AES, this is always 16
// Ensure the key is exactly 32 bytes
function getEncryptionKey(): Buffer {
const key = ENCRYPTION_KEY.padEnd(32, '0').substring(0, 32);
return Buffer.from(key, 'utf8');
}
/**
* Encrypts a plain text string
* @param text - The plain text to encrypt
* @returns Encrypted string in format: iv:encryptedData
*/
export function encrypt(text: string): string {
if (!text) return '';
try {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Return iv and encrypted data separated by ':'
return iv.toString('hex') + ':' + encrypted;
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypts an encrypted string
* @param encryptedText - The encrypted text in format: iv:encryptedData
* @returns Decrypted plain text string
*/
export function decrypt(encryptedText: string): string {
if (!encryptedText) return '';
try {
const key = getEncryptionKey();
// Check if this is the new format with IV
if (isEncrypted(encryptedText)) {
const textParts = encryptedText.split(':');
if (textParts.length !== 2) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(textParts[0], 'hex');
const encryptedData = textParts[1];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Check if this is legacy format
else if (isLegacyEncrypted(encryptedText)) {
try {
return legacyDecrypt(encryptedText);
} catch (legacyError) {
console.warn('Failed to decrypt legacy format, treating as plain text:', legacyError);
// If we can't decrypt legacy format, assume it's plain text
return encryptedText;
}
}
// If it doesn't match any encrypted format, assume it's plain text
else {
return encryptedText;
}
} catch (error) {
console.error('Decryption error:', error);
// If decryption fails completely, return the original text
// This prevents the app from crashing if there's corrupted data
console.warn('Returning original text due to decryption failure');
return encryptedText;
}
}
/**
* Encrypts sensitive mail settings
* @param settings - Mail settings object
* @returns Mail settings with encrypted password
*/
export function encryptMailSettings(settings: {
host: string;
port: number;
secure: boolean;
username: string;
password: string;
fromName: string;
fromEmail: string;
}) {
return {
...settings,
password: encrypt(settings.password)
};
}
/**
* Decrypts sensitive mail settings
* @param settings - Mail settings object with encrypted password
* @returns Mail settings with decrypted password
*/
export function decryptMailSettings(settings: {
host: string;
port: number;
secure: boolean;
username: string;
password: string;
fromName: string;
fromEmail: string;
}) {
try {
return {
...settings,
password: decrypt(settings.password)
};
} catch (error) {
console.error('Failed to decrypt mail settings password:', error);
// Return settings with empty password if decryption fails
// This prevents the app from crashing
return {
...settings,
password: ''
};
}
}
/**
* Legacy decrypt function for backward compatibility with old encryption method
* @param encryptedText - The encrypted text
* @returns Decrypted plain text string
*/
function legacyDecrypt(encryptedText: string): string {
try {
const key = getEncryptionKey();
// Try to use the old createDecipher method if available (for local development)
// This is a fallback for data encrypted with the old method
if (typeof (crypto as any).createDecipher === 'function') {
const decipher = (crypto as any).createDecipher(ALGORITHM, key);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} else {
// If old method is not available, we can't decrypt old format
throw new Error('Cannot decrypt legacy format in this Node.js version');
}
} catch (error) {
throw error;
}
}
/**
* Validates if a string is encrypted (contains ':' separator)
* @param text - Text to validate
* @returns True if text appears to be encrypted
*/
export function isEncrypted(text: string): boolean {
return text.includes(':') && text.split(':').length === 2;
}
/**
* Checks if encrypted text is in legacy format (no IV separator)
* @param text - Text to check
* @returns True if text appears to be in legacy format
*/
export function isLegacyEncrypted(text: string): boolean {
// Legacy format doesn't have ':' separator or has different pattern
return !text.includes(':') && text.length > 0 && /^[a-f0-9]+$/i.test(text);
}
/**
* Safely encrypts a password only if it's not already encrypted
* @param password - Password to encrypt
* @returns Encrypted password
*/
export function safeEncryptPassword(password: string): string {
if (isEncrypted(password)) {
return password; // Already encrypted
}
return encrypt(password);
}
/**
* Migrates legacy encrypted data to new format
* @param encryptedText - Potentially legacy encrypted text
* @returns Re-encrypted text in new format
*/
export function migrateEncryption(encryptedText: string): string {
if (!encryptedText) return '';
try {
// If it's already in new format, return as-is
if (isEncrypted(encryptedText)) {
// Verify it can be decrypted and re-encrypt to ensure consistency
const decrypted = decrypt(encryptedText);
return encrypt(decrypted);
}
// If it's in legacy format, decrypt and re-encrypt
if (isLegacyEncrypted(encryptedText)) {
const decrypted = legacyDecrypt(encryptedText);
return encrypt(decrypted);
}
// If it's plain text, encrypt it
return encrypt(encryptedText);
} catch (error) {
console.error('Migration error:', error);
// If migration fails, try to encrypt as plain text
try {
return encrypt(encryptedText);
} catch (encryptError) {
console.error('Failed to encrypt during migration:', encryptError);
return encryptedText;
}
}
}
/**
* Test encryption/decryption functionality
* @param testText - Text to test with
* @returns Test results
*/
export function testEncryption(testText: string = 'test-password-123') {
try {
const encrypted = encrypt(testText);
const decrypted = decrypt(encrypted);
return {
success: decrypted === testText,
original: testText,
encrypted: encrypted,
decrypted: decrypted,
isValid: decrypted === testText,
format: 'new'
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
format: 'unknown'
};
}
}