255 lines
7.4 KiB
TypeScript
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'
|
|
};
|
|
}
|
|
} |