261 lines
7.5 KiB
TypeScript
Executable File
261 lines
7.5 KiB
TypeScript
Executable File
import { log } from '../utils/logger';
|
|
|
|
/**
|
|
* Client-side password encryption service
|
|
* Uses Web Crypto API with AES-256-GCM algorithm
|
|
*/
|
|
class EncryptionService {
|
|
private encryptionKey: CryptoKey | null = null;
|
|
|
|
/**
|
|
* Initialize encryption key
|
|
* @param keyBase64 Base64 encoded key from server
|
|
*/
|
|
async initializeKey(keyBase64: string): Promise<void> {
|
|
try {
|
|
// this._keyBase64 = keyBase64;
|
|
|
|
// Decode Base64 key
|
|
const keyBuffer = this.base64ToArrayBuffer(keyBase64);
|
|
|
|
// Import key for use with Web Crypto API
|
|
this.encryptionKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
keyBuffer,
|
|
{ name: 'AES-GCM' },
|
|
false, // not exportable
|
|
['encrypt', 'decrypt']
|
|
);
|
|
|
|
log.info('encryption-service', 'Encryption key initialized successfully');
|
|
} catch (error) {
|
|
log.error('encryption-service', 'Failed to initialize encryption key', { error });
|
|
throw new Error('Failed to initialize encryption key');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encrypt password using AES-GCM and AEAD
|
|
* @param password Password in plain text
|
|
* @param sessionId Session ID for AAD
|
|
* @returns Encrypted password in Base64
|
|
*/
|
|
async encryptPassword(password: string, sessionId?: string): Promise<{encrypted: string, nonce: string, timestamp: number}> {
|
|
if (!this.encryptionKey) {
|
|
throw new Error('Encryption key not initialized');
|
|
}
|
|
|
|
try {
|
|
// CRITICAL: Generate unique IV (12 bytes for GCM)
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
// Convert password to ArrayBuffer
|
|
const passwordBuffer = new TextEncoder().encode(password);
|
|
|
|
// AAD used ONLY for saved machines (when sessionId exists)
|
|
// For temporary connection passwords AAD is NOT needed
|
|
const encryptParams: AesGcmParams = {
|
|
name: 'AES-GCM',
|
|
iv: iv
|
|
};
|
|
|
|
let timestamp = 0;
|
|
let clientNonce: Uint8Array | undefined;
|
|
|
|
// If sessionId provided - use AAD for additional security
|
|
if (sessionId) {
|
|
timestamp = Date.now();
|
|
clientNonce = crypto.getRandomValues(new Uint8Array(16));
|
|
const aadData = {
|
|
sessionId: sessionId,
|
|
timestamp: timestamp,
|
|
clientNonce: Array.from(clientNonce)
|
|
};
|
|
encryptParams.additionalData = new TextEncoder().encode(JSON.stringify(aadData));
|
|
}
|
|
|
|
// Encrypt
|
|
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
encryptParams,
|
|
this.encryptionKey,
|
|
passwordBuffer
|
|
);
|
|
|
|
// Combine IV + encrypted data
|
|
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
|
|
combined.set(iv, 0);
|
|
combined.set(new Uint8Array(encryptedBuffer), iv.length);
|
|
|
|
// Convert to Base64
|
|
const encrypted = this.arrayBufferToBase64(combined.buffer);
|
|
const nonce = clientNonce ? this.arrayBufferToBase64(clientNonce.buffer as ArrayBuffer) : '';
|
|
|
|
log.debug('encryption-service', 'Password encrypted successfully', {
|
|
passwordLength: password.length,
|
|
encryptedLength: encrypted.length,
|
|
sessionId: sessionId,
|
|
usesAAD: !!sessionId
|
|
});
|
|
|
|
return {
|
|
encrypted,
|
|
nonce,
|
|
timestamp
|
|
};
|
|
} catch (error) {
|
|
log.error('encryption-service', 'Failed to encrypt password', { error });
|
|
throw new Error('Password encryption failed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt password (for testing)
|
|
* @param encryptedPassword Base64 encrypted string
|
|
* @param sessionId Session ID for AAD (optional - for testing)
|
|
* @param nonce Client nonce for AAD (optional - for testing)
|
|
* @param timestamp Timestamp for AAD (optional - for testing)
|
|
* @returns Password in plain text
|
|
*/
|
|
async decryptPassword(
|
|
encryptedPassword: string,
|
|
sessionId?: string,
|
|
nonce?: string,
|
|
timestamp?: number
|
|
): Promise<string> {
|
|
if (!this.encryptionKey) {
|
|
throw new Error('Encryption key not initialized');
|
|
}
|
|
|
|
try {
|
|
// Decode Base64
|
|
const combined = this.base64ToArrayBuffer(encryptedPassword);
|
|
|
|
// Extract IV (first 12 bytes) and encrypted data
|
|
const iv = combined.slice(0, 12);
|
|
const encryptedData = combined.slice(12);
|
|
|
|
// FIXED: Create AAD if parameters provided
|
|
let additionalData: Uint8Array | undefined = undefined;
|
|
if (sessionId && nonce && timestamp) {
|
|
const clientNonceArray = new Uint8Array(this.base64ToArrayBuffer(nonce));
|
|
const aadData = {
|
|
sessionId: sessionId,
|
|
timestamp: timestamp,
|
|
clientNonce: Array.from(clientNonceArray)
|
|
};
|
|
additionalData = new TextEncoder().encode(JSON.stringify(aadData));
|
|
}
|
|
|
|
// Decrypt
|
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
{
|
|
name: 'AES-GCM',
|
|
iv: iv,
|
|
...(additionalData && { additionalData }) // Add AAD if exists
|
|
},
|
|
this.encryptionKey,
|
|
encryptedData
|
|
);
|
|
|
|
// Convert back to string
|
|
const password = new TextDecoder().decode(decryptedBuffer);
|
|
|
|
log.debug('encryption-service', 'Password decrypted successfully');
|
|
|
|
return password;
|
|
} catch (error) {
|
|
log.error('encryption-service', 'Failed to decrypt password', { error });
|
|
throw new Error('Password decryption failed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if encryption key is initialized
|
|
*/
|
|
isInitialized(): boolean {
|
|
return this.encryptionKey !== null;
|
|
}
|
|
|
|
/**
|
|
* Get current encryption status
|
|
*/
|
|
getStatus(): {
|
|
initialized: boolean;
|
|
} {
|
|
return {
|
|
initialized: this.isInitialized(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear encryption key
|
|
*/
|
|
clearKey(): void {
|
|
this.encryptionKey = null;
|
|
log.info('encryption-service', 'Encryption key cleared');
|
|
}
|
|
|
|
/**
|
|
* Convert Base64 string to ArrayBuffer
|
|
*/
|
|
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
|
const binaryString = atob(base64);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
}
|
|
|
|
/**
|
|
* Convert ArrayBuffer to Base64 string
|
|
*/
|
|
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
/**
|
|
* Test encryption/decryption
|
|
*/
|
|
async testEncryption(testPassword: string = 'test123', sessionId: string = 'test-session'): Promise<boolean> {
|
|
try {
|
|
if (!this.isInitialized()) {
|
|
throw new Error('Encryption key not initialized');
|
|
}
|
|
|
|
// Encrypt test password
|
|
const encryptionResult = await this.encryptPassword(testPassword, sessionId);
|
|
|
|
// FIXED: Decrypt with same AAD parameters
|
|
const decrypted = await this.decryptPassword(
|
|
encryptionResult.encrypted,
|
|
sessionId,
|
|
encryptionResult.nonce,
|
|
encryptionResult.timestamp
|
|
);
|
|
|
|
// Check that we got the same password
|
|
const success = decrypted === testPassword;
|
|
|
|
log.info('encryption-service', 'Encryption test completed', {
|
|
success,
|
|
testPasswordLength: testPassword.length,
|
|
encryptedLength: encryptionResult.encrypted.length
|
|
});
|
|
|
|
return success;
|
|
} catch (error) {
|
|
log.error('encryption-service', 'Encryption test failed', { error });
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const encryptionService = new EncryptionService();
|
|
export default encryptionService;
|