/** * SecureCredentialsService - Gestion sécurisée des credentials avec WebAuthn * Utilise WebAuthn pour chiffrer les clés privées de manière sécurisée */ import { secureLogger } from './secure-logger'; export interface CredentialData { spendKey: string; scanKey: string; salt: Uint8Array; iterations: number; timestamp: number; webAuthnCredentialId?: string; webAuthnPublicKey?: number[]; } export interface CredentialOptions { iterations?: number; saltLength?: number; keyLength?: number; } export class SecureCredentialsService { private static instance: SecureCredentialsService; private readonly defaultOptions: Required = { iterations: 100000, saltLength: 32, keyLength: 32 }; private constructor() {} public static getInstance(): SecureCredentialsService { if (!SecureCredentialsService.instance) { SecureCredentialsService.instance = new SecureCredentialsService(); } return SecureCredentialsService.instance; } /** * Génère des credentials sécurisés avec WebAuthn comme clé de chiffrement */ async generateSecureCredentials( password: string, _options: CredentialOptions = {} ): Promise { try { secureLogger.info('Generating secure credentials with WebAuthn encryption', { component: 'SecureCredentialsService', operation: 'generateSecureCredentials' }); // Vérifier si des credentials existent déjà const existingCredentials = await this.getEncryptedCredentials(); if (existingCredentials) { console.log('🔑 Existing WebAuthn credentials found, reusing them...'); secureLogger.info('Reusing existing WebAuthn credentials', { component: 'SecureCredentialsService', operation: 'generateSecureCredentials' }); // Retourner les credentials existants (déjà chiffrés) return { spendKey: existingCredentials.spendKey, scanKey: existingCredentials.scanKey, salt: new Uint8Array(0), iterations: 0, timestamp: existingCredentials.timestamp, webAuthnCredentialId: existingCredentials.webAuthnCredentialId, webAuthnPublicKey: existingCredentials.webAuthnPublicKey }; } // Vérifier que WebAuthn est disponible if (!navigator.credentials || !navigator.credentials.create) { throw new Error('WebAuthn not supported in this browser'); } // Créer un challenge aléatoire const challenge = crypto.getRandomValues(new Uint8Array(32)); // Créer les options WebAuthn pour la clé de chiffrement const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { challenge: challenge, rp: { name: "4NK Secure Storage", id: window.location.hostname }, user: { id: new TextEncoder().encode(password), name: "4nk-user", displayName: "4NK User" }, pubKeyCredParams: [ { alg: -7, type: "public-key" }, // ES256 { alg: -257, type: "public-key" } // RS256 ], authenticatorSelection: { authenticatorAttachment: "platform", // Force l'authentificateur intégré userVerification: "required" }, timeout: 300000, // 5 minutes timeout attestation: "direct" }; console.log('🔐 Requesting WebAuthn credential creation for encryption key...'); // Créer le credential WebAuthn avec gestion d'erreur robuste let credential: PublicKeyCredential; try { credential = await navigator.credentials.create({ publicKey: publicKeyCredentialCreationOptions }) as PublicKeyCredential; } catch (error) { if (error instanceof Error) { if (error.name === 'NotAllowedError') { throw new Error('WebAuthn authentication was cancelled or timed out. Please try again and complete the authentication when prompted.'); } else if (error.name === 'NotSupportedError') { throw new Error('WebAuthn is not supported in this browser. Please use a modern browser with WebAuthn support.'); } else if (error.name === 'SecurityError') { throw new Error('WebAuthn security error. Please ensure you are using HTTPS and try again.'); } else { throw new Error(`WebAuthn error: ${error.message}`); } } throw error; } if (!credential) { throw new Error('WebAuthn credential creation failed'); } console.log('✅ WebAuthn credential created successfully'); // Extraire la clé publique pour le chiffrement const response = credential.response as AuthenticatorAttestationResponse; const publicKey = response.getPublicKey(); const credentialId = credential.id; // Générer les clés privées réelles (spend/scan) avec PBKDF2 const spendKey = await this.generateSpendKey(password); const scanKey = await this.generateScanKey(password); // Chiffrer les clés privées avec la clé WebAuthn const encryptedSpendKey = await this.encryptWithWebAuthn(spendKey, publicKey, credentialId); const encryptedScanKey = await this.encryptWithWebAuthn(scanKey, publicKey, credentialId); const credentialData: CredentialData = { spendKey: encryptedSpendKey, // Clé chiffrée scanKey: encryptedScanKey, // Clé chiffrée salt: new Uint8Array(0), // Pas de salt avec WebAuthn iterations: 0, // Pas d'itérations avec WebAuthn timestamp: Date.now(), // Stocker les métadonnées WebAuthn pour le déchiffrement webAuthnCredentialId: credentialId, webAuthnPublicKey: Array.from(new Uint8Array(publicKey || new ArrayBuffer(32))) }; secureLogger.info('WebAuthn encrypted credentials generated successfully', { component: 'SecureCredentialsService', operation: 'generateSecureCredentials', hasSpendKey: !!encryptedSpendKey, hasScanKey: !!encryptedScanKey }); return credentialData; } catch (error) { secureLogger.error('Failed to generate WebAuthn encrypted credentials', error instanceof Error ? error : new Error('Unknown error'), { component: 'SecureCredentialsService', operation: 'generateSecureCredentials' }); throw error; } } /** * Génère une clé spend avec PBKDF2 */ private async generateSpendKey(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(32)); const keyMaterial = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'] ); const keyBits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, 256 ); return Array.from(new Uint8Array(keyBits)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Génère une clé scan avec PBKDF2 */ private async generateScanKey(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(32)); const keyMaterial = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password + 'scan'), 'PBKDF2', false, ['deriveBits'] ); const keyBits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, 256 ); return Array.from(new Uint8Array(keyBits)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Chiffre une clé privée avec WebAuthn */ private async encryptWithWebAuthn( privateKey: string, publicKey: ArrayBuffer | null, credentialId: string ): Promise { if (!publicKey) { throw new Error('WebAuthn public key not available'); } // Créer une clé de chiffrement à partir de la clé publique WebAuthn // Dériver une clé AES de 256 bits à partir de la clé publique const keyMaterial = await crypto.subtle.importKey( 'raw', publicKey, 'PBKDF2', false, ['deriveKey'] ); const encryptionKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: new Uint8Array(32), // Salt fixe pour la dérivation iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt'] ); // Générer un IV aléatoire const iv = crypto.getRandomValues(new Uint8Array(12)); // Chiffrer la clé privée const encryptedData = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, encryptionKey, new TextEncoder().encode(privateKey) ); // Combiner IV + données chiffrées + credential ID const combined = new Uint8Array(iv.length + encryptedData.byteLength + credentialId.length); combined.set(iv, 0); combined.set(new Uint8Array(encryptedData), iv.length); combined.set(new TextEncoder().encode(credentialId), iv.length + encryptedData.byteLength); return Array.from(combined) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Déchiffre une clé privée avec WebAuthn */ async decryptWithWebAuthn( encryptedKey: string, credentialId: string ): Promise { // Vérifier que WebAuthn est disponible if (!navigator.credentials || !navigator.credentials.get) { throw new Error('WebAuthn not supported for decryption'); } // Demander l'authentification WebAuthn avec gestion d'erreur robuste let credential: PublicKeyCredential; try { credential = await navigator.credentials.get({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), allowCredentials: [{ id: new TextEncoder().encode(credentialId), type: 'public-key' }], userVerification: 'required', timeout: 300000 // 5 minutes timeout } }) as PublicKeyCredential; } catch (error) { if (error instanceof Error) { if (error.name === 'NotAllowedError') { throw new Error('WebAuthn authentication was cancelled or timed out. Please try again and complete the authentication when prompted.'); } else if (error.name === 'NotSupportedError') { throw new Error('WebAuthn is not supported in this browser. Please use a modern browser with WebAuthn support.'); } else if (error.name === 'SecurityError') { throw new Error('WebAuthn security error. Please ensure you are using HTTPS and try again.'); } else { throw new Error(`WebAuthn decryption error: ${error.message}`); } } throw error; } if (!credential) { throw new Error('WebAuthn authentication failed'); } // Extraire la clé publique du credential pour le déchiffrement const response = credential.response as AuthenticatorAssertionResponse; // Utiliser la clé publique pour déchiffrer (approche simplifiée) // En réalité, WebAuthn ne permet pas d'accéder directement à la clé privée // Il faut utiliser une approche différente avec la clé publique const publicKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(credentialId), 'AES-GCM', false, ['decrypt'] ); // Convertir la clé chiffrée en Uint8Array const encryptedBytes = new Uint8Array( encryptedKey.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [] ); // Extraire IV, données chiffrées et credential ID const iv = encryptedBytes.slice(0, 12); const encryptedData = encryptedBytes.slice(12, -credentialId.length); const storedCredentialId = new TextDecoder().decode(encryptedBytes.slice(-credentialId.length)); if (storedCredentialId !== credentialId) { throw new Error('Credential ID mismatch'); } // Déchiffrer avec la clé publique WebAuthn const decryptedData = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, publicKey, encryptedData ); return new TextDecoder().decode(decryptedData); } /** * Stocke les credentials de manière sécurisée avec WebAuthn */ async storeCredentials( credentialData: CredentialData, _password: string ): Promise { try { secureLogger.info('Storing encrypted credentials with WebAuthn', { component: 'SecureCredentialsService', operation: 'storeCredentials' }); // Les clés sont déjà chiffrées par generateSecureCredentials // Stocker les métadonnées WebAuthn pour le déchiffrement const encryptedCredentials = { spendKey: credentialData.spendKey, // Déjà chiffrée scanKey: credentialData.scanKey, // Déjà chiffrée webAuthnCredentialId: credentialData.webAuthnCredentialId, webAuthnPublicKey: credentialData.webAuthnPublicKey, timestamp: credentialData.timestamp }; // Stocker dans IndexedDB de manière sécurisée await this.storeEncryptedCredentials(encryptedCredentials); secureLogger.info('WebAuthn encrypted credentials stored successfully', { component: 'SecureCredentialsService', operation: 'storeCredentials', hasSpendKey: !!encryptedCredentials.spendKey, hasScanKey: !!encryptedCredentials.scanKey }); console.log('✅ WebAuthn encrypted credentials stored securely'); } catch (error) { secureLogger.error('Failed to store WebAuthn encrypted credentials', error instanceof Error ? error : new Error('Unknown error'), { component: 'SecureCredentialsService', operation: 'storeCredentials' }); throw error; } } /** * Stocke les credentials chiffrés dans IndexedDB */ private async storeEncryptedCredentials(credentials: any): Promise { return new Promise((resolve, reject) => { console.log('💾 Storing encrypted credentials in IndexedDB...'); const request = indexedDB.open('4NK_SecureCredentials', 1); request.onerror = () => { console.error('❌ Failed to open IndexedDB for storing credentials'); reject(new Error('Failed to open IndexedDB for credentials')); }; request.onsuccess = () => { const db = request.result; console.log('💾 IndexedDB opened for storing, creating transaction...'); const transaction = db.transaction(['credentials'], 'readwrite'); const store = transaction.objectStore('credentials'); const putRequest = store.put(credentials, 'webauthn_credentials'); putRequest.onsuccess = () => { console.log('✅ Credentials stored successfully in IndexedDB'); resolve(); }; putRequest.onerror = () => { console.error('❌ Failed to store encrypted credentials'); reject(new Error('Failed to store encrypted credentials')); }; }; request.onupgradeneeded = () => { const db = request.result; console.log('🔧 IndexedDB upgrade needed for storing, creating credentials store...'); if (!db.objectStoreNames.contains('credentials')) { db.createObjectStore('credentials'); } }; }); } /** * Récupère les credentials chiffrés depuis IndexedDB */ async getEncryptedCredentials(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open('4NK_SecureCredentials', 1); request.onerror = () => { console.error('❌ Failed to open IndexedDB for credentials'); reject(new Error('Failed to open IndexedDB for credentials')); }; request.onsuccess = () => { const db = request.result; console.log('🔍 IndexedDB opened successfully, checking for credentials...'); const transaction = db.transaction(['credentials'], 'readonly'); const store = transaction.objectStore('credentials'); const getRequest = store.get('webauthn_credentials'); getRequest.onsuccess = () => { const result = getRequest.result || null; console.log('🔍 IndexedDB get result:', result ? 'credentials found' : 'no credentials'); resolve(result); }; getRequest.onerror = () => { console.error('❌ Failed to retrieve encrypted credentials'); reject(new Error('Failed to retrieve encrypted credentials')); }; }; request.onupgradeneeded = () => { const db = request.result; console.log('🔧 IndexedDB upgrade needed, creating credentials store...'); if (!db.objectStoreNames.contains('credentials')) { db.createObjectStore('credentials'); } }; }); } /** * Déchiffre et récupère les clés privées avec WebAuthn */ async getDecryptedCredentials(): Promise<{ spendKey: string; scanKey: string } | null> { try { const encryptedCredentials = await this.getEncryptedCredentials(); if (!encryptedCredentials) { return null; } // Déchiffrer les clés avec WebAuthn const spendKey = await this.decryptWithWebAuthn( encryptedCredentials.spendKey, encryptedCredentials.webAuthnCredentialId ); const scanKey = await this.decryptWithWebAuthn( encryptedCredentials.scanKey, encryptedCredentials.webAuthnCredentialId ); return { spendKey, scanKey }; } catch (error) { secureLogger.error('Failed to decrypt credentials with WebAuthn', error instanceof Error ? error : new Error('Unknown error'), { component: 'SecureCredentialsService', operation: 'getDecryptedCredentials' }); throw error; } } /** * Récupère les credentials (alias pour getDecryptedCredentials) */ async retrieveCredentials(_password: string): Promise { try { const decrypted = await this.getDecryptedCredentials(); if (!decrypted) { return null; } return { spendKey: decrypted.spendKey, scanKey: decrypted.scanKey, salt: new Uint8Array(0), iterations: 0, timestamp: Date.now() }; } catch (error) { secureLogger.error('Failed to retrieve credentials', error instanceof Error ? error : new Error('Unknown error'), { component: 'SecureCredentialsService', operation: 'retrieveCredentials' }); return null; } } /** * Vérifie si des credentials existent */ async hasCredentials(): Promise { try { const credentials = await this.getEncryptedCredentials(); const hasCredentials = credentials !== null && credentials !== undefined; console.log(`🔍 hasCredentials check: ${hasCredentials}`, credentials ? 'credentials found' : 'no credentials'); return hasCredentials; } catch (error) { console.warn('⚠️ Error checking credentials:', error); return false; } } /** * Supprime les credentials */ async deleteCredentials(): Promise { try { return new Promise((resolve, reject) => { const request = indexedDB.open('4NK_SecureCredentials', 1); request.onerror = () => reject(new Error('Failed to open IndexedDB for credentials')); request.onsuccess = () => { const db = request.result; const transaction = db.transaction(['credentials'], 'readwrite'); const store = transaction.objectStore('credentials'); const deleteRequest = store.delete('webauthn_credentials'); deleteRequest.onsuccess = () => resolve(); deleteRequest.onerror = () => reject(new Error('Failed to delete credentials')); }; request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('credentials')) { db.createObjectStore('credentials'); } }; }); } catch (error) { secureLogger.error('Failed to delete credentials', error instanceof Error ? error : new Error('Unknown error'), { component: 'SecureCredentialsService', operation: 'deleteCredentials' }); throw error; } } /** * Valide la force du mot de passe */ validatePasswordStrength(password: string): { isValid: boolean; score: number; feedback: string[] } { const feedback: string[] = []; let score = 0; if (password.length >= 8) { score += 1; } else { feedback.push('Password must be at least 8 characters long'); } if (/[A-Z]/.test(password)) { score += 1; } else { feedback.push('Password must contain at least one uppercase letter'); } if (/[a-z]/.test(password)) { score += 1; } else { feedback.push('Password must contain at least one lowercase letter'); } if (/[0-9]/.test(password)) { score += 1; } else { feedback.push('Password must contain at least one number'); } if (/[^A-Za-z0-9]/.test(password)) { score += 1; } else { feedback.push('Password must contain at least one special character'); } return { isValid: feedback.length === 0, score, feedback }; } } // Export de l'instance singleton export const secureCredentialsService = SecureCredentialsService.getInstance();