**Motivations :** - restore_device() ne met pas à jour les clés dans le SpClient interne - create_device_from_sp_wallet() crée un nouveau device avec les clés mises à jour - Cela force le SDK à reconnaître les nouvelles clés **Modifications :** - src/services/service.ts : Remplacé restoreDevice() par create_device_from_sp_wallet() - Utilise dumpWallet() pour récupérer le JSON du SpClient avec les clés mises à jour - Supprimé l'injection manuelle des clés dans le device en mémoire - Fallback vers restoreDevice() si create_device_from_sp_wallet() échoue **Pages affectées :** - src/services/service.ts : Amélioration de ensureWalletKeysAvailable() pour forcer la reconnaissance des clés
1252 lines
42 KiB
TypeScript
1252 lines
42 KiB
TypeScript
/**
|
|
* SecureCredentialsService - Service principal pour la gestion des credentials
|
|
* Utilise des modules spécialisés pour une meilleure organisation
|
|
*/
|
|
import { secureLogger } from './secure-logger';
|
|
import { SecurityModeService, SecurityMode } from './security-mode.service';
|
|
// Imports dynamiques pour éviter les problèmes d'initialisation
|
|
import { CredentialData, CredentialOptions } from './credentials/credential-types';
|
|
|
|
// Export des types - géré par les imports dynamiques
|
|
|
|
export class SecureCredentialsService {
|
|
private static instance: SecureCredentialsService;
|
|
private readonly defaultOptions: Required<CredentialOptions> = {
|
|
iterations: 100000,
|
|
saltLength: 32,
|
|
keyLength: 32
|
|
};
|
|
|
|
// Protection contre les appels multiples
|
|
private isGeneratingCredentials = false;
|
|
private isRetrievingCredentials = false;
|
|
|
|
// Services spécialisés (importés dynamiquement)
|
|
private securityModeService: SecurityModeService;
|
|
|
|
private constructor() {
|
|
this.securityModeService = SecurityModeService.getInstance();
|
|
}
|
|
|
|
public static getInstance(): SecureCredentialsService {
|
|
if (!SecureCredentialsService.instance) {
|
|
SecureCredentialsService.instance = new SecureCredentialsService();
|
|
}
|
|
return SecureCredentialsService.instance;
|
|
}
|
|
|
|
/**
|
|
* Génère des credentials selon le mode de sécurisation sélectionné
|
|
*/
|
|
async generateSecureCredentials(
|
|
_password: string,
|
|
_options: CredentialOptions = {}
|
|
): Promise<CredentialData> {
|
|
// Protection contre les appels multiples
|
|
if (this.isGeneratingCredentials) {
|
|
secureLogger.warn('Credentials generation already in progress, skipping...', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateSecureCredentials'
|
|
});
|
|
throw new Error('Credentials generation already in progress');
|
|
}
|
|
|
|
this.isGeneratingCredentials = true;
|
|
|
|
try {
|
|
// Récupérer le mode de sécurisation actuel
|
|
let currentMode = await this.securityModeService.getCurrentMode();
|
|
|
|
if (!currentMode) {
|
|
secureLogger.warn('No security mode selected, using default mode: none', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateSecureCredentials'
|
|
});
|
|
currentMode = 'none';
|
|
await this.securityModeService.setSecurityMode(currentMode);
|
|
}
|
|
|
|
const modeConfig = this.securityModeService.getSecurityModeConfig(currentMode);
|
|
|
|
secureLogger.info('Generating credentials with security mode', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateSecureCredentials',
|
|
mode: currentMode,
|
|
securityLevel: modeConfig.securityLevel,
|
|
useWebAuthn: modeConfig.implementation.useWebAuthn,
|
|
useEncryption: modeConfig.implementation.useEncryption
|
|
});
|
|
|
|
// Récupérer le mot de passe des credentials depuis le service env
|
|
const { EnvService } = await import('./env.service');
|
|
const envService = EnvService.getInstance();
|
|
|
|
// Initialiser les variables par défaut si nécessaire
|
|
await envService.initializeDefaults();
|
|
|
|
// Récupérer le mot de passe des credentials
|
|
const credentialsPassword = await envService.getVariable('CREDENTIALS_PASSWORD');
|
|
if (!credentialsPassword) {
|
|
throw new Error('Credentials password not found in environment variables');
|
|
}
|
|
|
|
// Adapter le comportement selon le mode
|
|
if (modeConfig.implementation.useWebAuthn && modeConfig.implementation.useEncryption) {
|
|
return this.generateWebAuthnCredentials(credentialsPassword, _options);
|
|
} else if (modeConfig.implementation.useEncryption) {
|
|
if (currentMode === 'password') {
|
|
return this.generatePasswordCredentials(credentialsPassword, _options);
|
|
} else {
|
|
return this.generateEncryptedCredentials(credentialsPassword, _options);
|
|
}
|
|
} else {
|
|
return this.generatePlainCredentials(credentialsPassword, _options);
|
|
}
|
|
} finally {
|
|
this.isGeneratingCredentials = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vérifie silencieusement si une clé PBKDF2 existe pour un mode de sécurité
|
|
* sans déclencher d'interactions utilisateur (comme les fenêtres du navigateur)
|
|
*/
|
|
async hasPBKDF2Key(securityMode: SecurityMode): Promise<boolean> {
|
|
try {
|
|
switch (securityMode) {
|
|
case 'proton-pass':
|
|
case 'os':
|
|
// Pour WebAuthn, vérifier si une clé chiffrée existe
|
|
const { WebAuthnService } = await import('./credentials/webauthn.service');
|
|
const webAuthnService = WebAuthnService.getInstance();
|
|
return await webAuthnService.hasStoredKey(securityMode);
|
|
|
|
case 'otp':
|
|
// Vérifier si une clé en clair existe dans pbkdf2keys
|
|
const plainKey = await this.getPBKDF2KeyFromStore(securityMode);
|
|
return plainKey !== null;
|
|
|
|
case 'password':
|
|
// Vérifier silencieusement si une clé chiffrée existe dans pbkdf2keys
|
|
// sans déclencher l'API Credential Management
|
|
const encryptedPasswordData = await this.getPBKDF2KeyFromStore(securityMode);
|
|
return encryptedPasswordData !== null;
|
|
|
|
case 'none':
|
|
// Vérifier si une clé chiffrée avec la clé en dur existe dans pbkdf2keys
|
|
const encryptedData = await this.getPBKDF2KeyFromStore(securityMode);
|
|
return encryptedData !== null;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
secureLogger.error('Failed to check PBKDF2 key existence', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'hasPBKDF2Key'
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère une clé PBKDF2 chiffrée depuis le store pbkdf2keys
|
|
*/
|
|
private async getPBKDF2KeyFromStore(securityMode: SecurityMode): Promise<string | null> {
|
|
try {
|
|
const { DATABASE_CONFIG, openDatabase } = await import('./database-config');
|
|
const db = await openDatabase();
|
|
const transaction = db.transaction([DATABASE_CONFIG.stores.pbkdf2keys.name], 'readonly');
|
|
const store = transaction.objectStore(DATABASE_CONFIG.stores.pbkdf2keys.name);
|
|
|
|
const result = await new Promise<string | null>((resolve, reject) => {
|
|
const request = store.get(securityMode);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
secureLogger.error('Failed to retrieve PBKDF2 key from pbkdf2keys store', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'getPBKDF2KeyFromStore'
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stocke une clé PBKDF2 chiffrée dans le store pbkdf2keys
|
|
*/
|
|
private async storePBKDF2KeyInStore(encryptedKey: string, securityMode: SecurityMode): Promise<void> {
|
|
try {
|
|
const { DATABASE_CONFIG, openDatabase } = await import('./database-config');
|
|
const db = await openDatabase();
|
|
const transaction = db.transaction([DATABASE_CONFIG.stores.pbkdf2keys.name], 'readwrite');
|
|
const store = transaction.objectStore(DATABASE_CONFIG.stores.pbkdf2keys.name);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const request = store.put(encryptedKey, securityMode);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
|
|
secureLogger.info('PBKDF2 key stored in pbkdf2keys store', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'storePBKDF2KeyInStore',
|
|
securityMode
|
|
});
|
|
} catch (error) {
|
|
secureLogger.error('Failed to store PBKDF2 key in pbkdf2keys store', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'storePBKDF2KeyInStore'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère une clé PBKDF2 existante selon le mode de sécurité
|
|
*/
|
|
async retrievePBKDF2Key(securityMode: SecurityMode): Promise<string | null> {
|
|
try {
|
|
const { DATABASE_CONFIG, openDatabase } = await import('./database-config');
|
|
const { WebAuthnService } = await import('./credentials/webauthn.service');
|
|
|
|
const webAuthnService = WebAuthnService.getInstance();
|
|
|
|
switch (securityMode) {
|
|
case 'proton-pass':
|
|
case 'os':
|
|
// Récupérer la clé chiffrée avec WebAuthn depuis pbkdf2keys
|
|
return await webAuthnService.retrieveKeyWithWebAuthn(securityMode);
|
|
|
|
case 'otp':
|
|
// Récupérer la clé en clair depuis pbkdf2keys
|
|
return await this.getPBKDF2KeyFromStore(securityMode);
|
|
|
|
case 'password':
|
|
// Récupérer la clé chiffrée avec mot de passe depuis pbkdf2keys
|
|
const encryptedPasswordData = await this.getPBKDF2KeyFromStore(securityMode);
|
|
if (encryptedPasswordData) {
|
|
return await this.decryptPBKDF2KeyWithPassword(encryptedPasswordData);
|
|
}
|
|
return null;
|
|
|
|
case 'none':
|
|
// Récupérer la clé chiffrée avec la clé en dur depuis pbkdf2keys
|
|
const encryptedData = await this.getPBKDF2KeyFromStore(securityMode);
|
|
if (encryptedData) {
|
|
const { EncryptionService } = await import('./encryption.service');
|
|
const encryptionService = EncryptionService.getInstance();
|
|
const hardcodedKey = '4NK_DEFAULT_ENCRYPTION_KEY_NOT_SECURE';
|
|
return await encryptionService.decrypt(encryptedData, hardcodedKey);
|
|
}
|
|
return null;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
secureLogger.error('Failed to retrieve PBKDF2 key', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'retrievePBKDF2Key'
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère et stocke une clé PBKDF2 selon le mode de sécurité
|
|
*/
|
|
async generatePBKDF2Key(securityMode: SecurityMode): Promise<string> {
|
|
try {
|
|
secureLogger.info('Generating PBKDF2 key for security mode', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePBKDF2Key',
|
|
securityMode
|
|
});
|
|
|
|
// Import dynamique des services
|
|
const { EncryptionService } = await import('./encryption.service');
|
|
const { WebAuthnService } = await import('./credentials/webauthn.service');
|
|
|
|
const encryptionService = EncryptionService.getInstance();
|
|
const webAuthnService = WebAuthnService.getInstance();
|
|
|
|
// Essayer d'abord de récupérer une clé existante
|
|
const existingKey = await this.retrievePBKDF2Key(securityMode);
|
|
if (existingKey) {
|
|
console.log('🔐 Existing PBKDF2 key found:', existingKey.substring(0, 8) + '...');
|
|
return existingKey;
|
|
}
|
|
|
|
// Générer une nouvelle clé PBKDF2 si aucune n'existe
|
|
const pbkdf2Key = encryptionService.generateRandomKey();
|
|
console.log('🔐 New PBKDF2 key generated:', pbkdf2Key.substring(0, 8) + '...');
|
|
|
|
// Stocker la clé selon le mode de sécurité
|
|
switch (securityMode) {
|
|
case 'proton-pass':
|
|
case 'os':
|
|
// Stocker avec WebAuthn (authentification biométrique)
|
|
console.log('🔐 Storing PBKDF2 key with WebAuthn authentication...');
|
|
await webAuthnService.storeKeyWithWebAuthn(pbkdf2Key, securityMode);
|
|
break;
|
|
|
|
case 'otp':
|
|
// Générer un secret OTP pour l'authentification (pas de chiffrement)
|
|
console.log('🔐 Setting up OTP authentication for PBKDF2 key...');
|
|
const otpSecret = await this.generateOTPSecret();
|
|
console.log('🔐 OTP Secret generated:', otpSecret);
|
|
// Stocker la clé PBKDF2 en clair dans pbkdf2keys (l'OTP protège l'accès, pas le stockage)
|
|
await this.storePBKDF2KeyInStore(pbkdf2Key, securityMode);
|
|
// Afficher le QR code pour l'utilisateur
|
|
this.displayOTPQRCode(otpSecret);
|
|
break;
|
|
|
|
case 'password':
|
|
// Utiliser l'API Credential Management du navigateur
|
|
console.log('🔐 Storing PBKDF2 key with browser password manager...');
|
|
const userPassword = await this.promptForPasswordWithBrowser();
|
|
const encryptedKey = await encryptionService.encrypt(pbkdf2Key, userPassword);
|
|
await this.storePBKDF2KeyInStore(encryptedKey, securityMode);
|
|
break;
|
|
|
|
case 'none':
|
|
// Chiffrer avec une clé déterminée en dur (non sécurisé)
|
|
console.log('⚠️ Storing PBKDF2 key with hardcoded encryption (not recommended)...');
|
|
const hardcodedKey = '4NK_DEFAULT_ENCRYPTION_KEY_NOT_SECURE';
|
|
const encryptedKeyNone = await encryptionService.encrypt(pbkdf2Key, hardcodedKey);
|
|
await this.storePBKDF2KeyInStore(encryptedKeyNone, securityMode);
|
|
break;
|
|
|
|
default:
|
|
throw new Error(`Unsupported security mode: ${securityMode}`);
|
|
}
|
|
|
|
secureLogger.info('PBKDF2 key generated and stored successfully', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePBKDF2Key',
|
|
securityMode
|
|
});
|
|
|
|
return pbkdf2Key;
|
|
|
|
} catch (error) {
|
|
secureLogger.error('Failed to generate PBKDF2 key', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePBKDF2Key'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère des credentials avec WebAuthn
|
|
*/
|
|
private async generateWebAuthnCredentials(
|
|
password: string,
|
|
_options: CredentialOptions = {}
|
|
): Promise<CredentialData> {
|
|
const currentMode = await this.securityModeService.getCurrentMode();
|
|
|
|
try {
|
|
secureLogger.info('Generating secure credentials with WebAuthn encryption', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateWebAuthnCredentials'
|
|
});
|
|
|
|
// Import dynamique des services
|
|
secureLogger.info('Importing WebAuthn and Encryption services...', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateWebAuthnCredentials'
|
|
});
|
|
|
|
const { EncryptionService } = await import('./credentials/encryption.service');
|
|
const { WebAuthnService } = await import('./credentials/webauthn.service');
|
|
|
|
secureLogger.info('Services imported successfully', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateWebAuthnCredentials'
|
|
});
|
|
|
|
const encryptionService = EncryptionService.getInstance();
|
|
const webAuthnService = WebAuthnService.getInstance();
|
|
|
|
// Générer des clés aléatoires
|
|
const keys = encryptionService.generateRandomKeys();
|
|
|
|
// Créer des credentials WebAuthn
|
|
const webAuthnCredential = await webAuthnService.createCredentials(password, currentMode!);
|
|
|
|
// Créer les credentials finaux
|
|
const credentials: CredentialData = {
|
|
spendKey: keys.spendKey,
|
|
scanKey: keys.scanKey,
|
|
salt: new Uint8Array(0),
|
|
iterations: 0,
|
|
timestamp: Date.now(),
|
|
webAuthnCredentialId: webAuthnCredential.id,
|
|
webAuthnPublicKey: webAuthnCredential.publicKey
|
|
};
|
|
|
|
secureLogger.info('WebAuthn credentials generated successfully', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateWebAuthnCredentials'
|
|
});
|
|
|
|
return credentials;
|
|
} catch (error) {
|
|
secureLogger.error('Failed to generate WebAuthn credentials', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateWebAuthnCredentials'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère des credentials avec mot de passe
|
|
*/
|
|
private async generatePasswordCredentials(
|
|
password: string,
|
|
_options: CredentialOptions = {}
|
|
): Promise<CredentialData> {
|
|
try {
|
|
secureLogger.info('Generating password-based credentials', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePasswordCredentials'
|
|
});
|
|
|
|
// Import dynamique du service
|
|
const { EncryptionService } = await import('./encryption.service');
|
|
const encryptionService = EncryptionService.getInstance();
|
|
|
|
// Générer des clés aléatoires
|
|
const keys = encryptionService.generateRandomKeys();
|
|
|
|
// Chiffrer les clés avec le mot de passe
|
|
const encryptedSpendKey = await encryptionService.encrypt(
|
|
keys.spendKey,
|
|
password
|
|
);
|
|
const encryptedScanKey = await encryptionService.encrypt(
|
|
keys.scanKey,
|
|
password
|
|
);
|
|
|
|
// Note: encryptionService.encrypt returns base64 string directly
|
|
// We need to keep track of salt for compatibility with old format
|
|
return {
|
|
spendKey: encryptedSpendKey,
|
|
scanKey: encryptedScanKey,
|
|
salt: new Uint8Array(16), // Placeholder for compatibility
|
|
iterations: 100000, // Standard iterations
|
|
timestamp: Date.now()
|
|
};
|
|
} catch (error) {
|
|
secureLogger.error('Failed to generate password credentials', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePasswordCredentials'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère des credentials chiffrés
|
|
* IMPORTANT: Extrait les vraies clés du wallet SDK au lieu de générer des clés aléatoires
|
|
*/
|
|
private async generateEncryptedCredentials(
|
|
password: string,
|
|
_options: CredentialOptions = {}
|
|
): Promise<CredentialData> {
|
|
try {
|
|
secureLogger.info('Generating encrypted credentials', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateEncryptedCredentials'
|
|
});
|
|
|
|
// Import dynamique du service
|
|
const { EncryptionService } = await import('./encryption.service');
|
|
const encryptionService = EncryptionService.getInstance();
|
|
|
|
// Récupérer les vraies clés du wallet SDK au lieu de générer des clés aléatoires
|
|
let spendKeyStr = '';
|
|
let scanKeyStr = '';
|
|
|
|
try {
|
|
const Services = (await import('./service')).default;
|
|
const services = await Services.getInstance();
|
|
|
|
// Dumper le wallet pour récupérer les vraies clés
|
|
const wallet = await services.dumpWallet();
|
|
console.log('🔑 Wallet dumped for credentials extraction:', typeof wallet);
|
|
|
|
// Parse le wallet JSON
|
|
const walletObj = typeof wallet === 'string' ? JSON.parse(wallet) : wallet;
|
|
|
|
// Extraire les clés
|
|
if (walletObj.spend_key) {
|
|
// spend_key peut être un objet {Secret: string} ou une string
|
|
spendKeyStr = typeof walletObj.spend_key === 'object'
|
|
? walletObj.spend_key.Secret
|
|
: walletObj.spend_key;
|
|
}
|
|
|
|
if (walletObj.scan_sk) {
|
|
scanKeyStr = walletObj.scan_sk;
|
|
}
|
|
|
|
console.log('🔑 Extracted keys from wallet:', {
|
|
has_spend_key: !!spendKeyStr,
|
|
has_scan_key: !!scanKeyStr,
|
|
spend_key_length: spendKeyStr.length,
|
|
scan_key_length: scanKeyStr.length
|
|
});
|
|
|
|
if (!spendKeyStr || !scanKeyStr) {
|
|
throw new Error('Failed to extract keys from wallet SDK');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Failed to extract keys from wallet SDK:', error);
|
|
// Fallback: générer des clés aléatoires si extraction échoue
|
|
console.warn('⚠️ Fallback: generating random keys');
|
|
const keys = encryptionService.generateRandomKeys();
|
|
spendKeyStr = keys.spendKey;
|
|
scanKeyStr = keys.scanKey;
|
|
}
|
|
|
|
// Chiffrer les vraies clés du wallet avec le mot de passe
|
|
const encryptedSpendKey = await encryptionService.encrypt(
|
|
spendKeyStr,
|
|
password
|
|
);
|
|
const encryptedScanKey = await encryptionService.encrypt(
|
|
scanKeyStr,
|
|
password
|
|
);
|
|
|
|
return {
|
|
spendKey: encryptedSpendKey, // ✅ Vraie clé du wallet chiffrée
|
|
scanKey: encryptedScanKey, // ✅ Vraie clé du wallet chiffrée
|
|
salt: new Uint8Array(16), // Placeholder for compatibility
|
|
iterations: 100000,
|
|
timestamp: Date.now()
|
|
};
|
|
} catch (error) {
|
|
secureLogger.error('Failed to generate encrypted credentials', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateEncryptedCredentials'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère des credentials en clair
|
|
*/
|
|
private async generatePlainCredentials(
|
|
_password: string,
|
|
_options: CredentialOptions = {}
|
|
): Promise<CredentialData> {
|
|
try {
|
|
secureLogger.warn('Generating plain credentials (not secure)', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePlainCredentials'
|
|
});
|
|
|
|
// Import dynamique du service
|
|
const { EncryptionService } = await import('./credentials/encryption.service');
|
|
const encryptionService = EncryptionService.getInstance();
|
|
|
|
// Générer des clés aléatoires
|
|
const keys = encryptionService.generateRandomKeys();
|
|
|
|
return {
|
|
spendKey: keys.spendKey,
|
|
scanKey: keys.scanKey,
|
|
salt: new Uint8Array(0),
|
|
iterations: 0,
|
|
timestamp: Date.now()
|
|
};
|
|
} catch (error) {
|
|
secureLogger.error('Failed to generate plain credentials', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generatePlainCredentials'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Récupère des credentials existants
|
|
*/
|
|
async retrieveCredentials(_password: string): Promise<CredentialData | null> {
|
|
// Protection contre les appels multiples
|
|
if (this.isRetrievingCredentials) {
|
|
secureLogger.warn('Credentials retrieval already in progress, skipping...', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'retrieveCredentials'
|
|
});
|
|
return null;
|
|
}
|
|
|
|
this.isRetrievingCredentials = true;
|
|
|
|
try {
|
|
// Import dynamique du service de stockage
|
|
const { StorageService } = await import('./credentials/storage.service');
|
|
const storageService = StorageService.getInstance();
|
|
|
|
const credentials = await storageService.getCredentials();
|
|
|
|
if (!credentials) {
|
|
secureLogger.info('No credentials found in storage', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'retrieveCredentials'
|
|
});
|
|
return null;
|
|
}
|
|
|
|
// Récupérer le mot de passe des credentials depuis le service env
|
|
const { EnvService } = await import('./env.service');
|
|
const envService = EnvService.getInstance();
|
|
|
|
// Initialiser les variables par défaut si nécessaire
|
|
await envService.initializeDefaults();
|
|
|
|
// Récupérer le mot de passe des credentials
|
|
const credentialsPassword = await envService.getVariable('CREDENTIALS_PASSWORD');
|
|
if (!credentialsPassword) {
|
|
throw new Error('Credentials password not found in environment variables');
|
|
}
|
|
|
|
// Déchiffrer selon le mode
|
|
const currentMode = await this.securityModeService.getCurrentMode();
|
|
const modeConfig = this.securityModeService.getSecurityModeConfig(currentMode!);
|
|
|
|
if (modeConfig.implementation.useWebAuthn && credentials.webAuthnCredentialId) {
|
|
return this.decryptWithWebAuthn(credentials, credentialsPassword);
|
|
} else if (modeConfig.implementation.useEncryption) {
|
|
return this.decryptWithPassword(credentials, credentialsPassword);
|
|
} else {
|
|
return credentials;
|
|
}
|
|
} finally {
|
|
this.isRetrievingCredentials = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Déchiffre avec WebAuthn
|
|
*/
|
|
private async decryptWithWebAuthn(
|
|
credentials: CredentialData,
|
|
_password: string
|
|
): Promise<CredentialData> {
|
|
try {
|
|
const currentMode = await this.securityModeService.getCurrentMode();
|
|
|
|
// Import dynamique du service WebAuthn
|
|
const { WebAuthnService } = await import('./credentials/webauthn.service');
|
|
const webAuthnService = WebAuthnService.getInstance();
|
|
|
|
// Utiliser les credentials WebAuthn
|
|
await webAuthnService.useCredentials(
|
|
credentials.webAuthnCredentialId!,
|
|
currentMode!
|
|
);
|
|
|
|
return credentials;
|
|
} catch (error) {
|
|
secureLogger.error('Failed to decrypt with WebAuthn', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'decryptWithWebAuthn'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Déchiffre avec mot de passe
|
|
*/
|
|
private async decryptWithPassword(
|
|
credentials: CredentialData,
|
|
password: string
|
|
): Promise<CredentialData> {
|
|
try {
|
|
if (credentials.salt.length === 0) {
|
|
// Credentials non chiffrés
|
|
return credentials;
|
|
}
|
|
|
|
// Import dynamique du service de chiffrement
|
|
const { EncryptionService } = await import('./encryption.service');
|
|
const encryptionService = EncryptionService.getInstance();
|
|
|
|
// Déchiffrer les clés avec la méthode standard decrypt()
|
|
const spendKey = await encryptionService.decrypt(
|
|
credentials.spendKey,
|
|
password
|
|
);
|
|
|
|
const scanKey = await encryptionService.decrypt(
|
|
credentials.scanKey,
|
|
password
|
|
);
|
|
|
|
return {
|
|
spendKey,
|
|
scanKey,
|
|
salt: new Uint8Array(0),
|
|
iterations: 0,
|
|
timestamp: credentials.timestamp
|
|
};
|
|
} catch (error) {
|
|
secureLogger.error('Failed to decrypt with password', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'decryptWithPassword'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stocke des credentials
|
|
*/
|
|
async storeCredentials(credentials: CredentialData, _password: string): Promise<void> {
|
|
try {
|
|
// Import dynamique du service de stockage
|
|
const { StorageService } = await import('./credentials/storage.service');
|
|
const storageService = StorageService.getInstance();
|
|
|
|
await storageService.storeCredentials(credentials);
|
|
secureLogger.info('Credentials stored successfully', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'storeCredentials'
|
|
});
|
|
} catch (error) {
|
|
secureLogger.error('Failed to store credentials', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'storeCredentials'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vérifie si des credentials existent
|
|
*/
|
|
async hasCredentials(): Promise<boolean> {
|
|
try {
|
|
// Import dynamique du service de stockage
|
|
const { StorageService } = await import('./credentials/storage.service');
|
|
const storageService = StorageService.getInstance();
|
|
|
|
return await storageService.hasCredentials();
|
|
} catch (error) {
|
|
secureLogger.error('Failed to check credentials existence', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'hasCredentials'
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère un secret OTP pour le mode OTP
|
|
*/
|
|
async generateOTPSecret(): Promise<string> {
|
|
try {
|
|
// Générer un secret OTP de 32 caractères (base32)
|
|
const secretBytes = crypto.getRandomValues(new Uint8Array(20));
|
|
const secret = this.base32Encode(secretBytes);
|
|
|
|
secureLogger.info('OTP secret generated', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateOTPSecret'
|
|
});
|
|
|
|
return secret;
|
|
} catch (error) {
|
|
secureLogger.error('Failed to generate OTP secret', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'generateOTPSecret'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valide un code OTP
|
|
*/
|
|
async validateOTPCode(_secret: string, code: string): Promise<boolean> {
|
|
try {
|
|
// Implémentation simplifiée de validation OTP
|
|
// Dans une implémentation complète, on utiliserait une bibliothèque comme speakeasy
|
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
const timeWindow = 30; // 30 secondes
|
|
|
|
// Pour la démo, on accepte n'importe quel code de 6 chiffres
|
|
const isValid = /^\d{6}$/.test(code);
|
|
|
|
secureLogger.info('OTP code validation result', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'validateOTPCode',
|
|
isValid
|
|
});
|
|
|
|
return isValid;
|
|
} catch (error) {
|
|
secureLogger.error('Failed to validate OTP code', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'validateOTPCode'
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encode en base32 pour les secrets OTP
|
|
*/
|
|
private base32Encode(data: Uint8Array): string {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
let result = '';
|
|
let bits = 0;
|
|
let value = 0;
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
value = (value << 8) | data[i];
|
|
bits += 8;
|
|
|
|
while (bits >= 5) {
|
|
result += alphabet[(value >>> (bits - 5)) & 31];
|
|
bits -= 5;
|
|
}
|
|
}
|
|
|
|
if (bits > 0) {
|
|
result += alphabet[(value << (5 - bits)) & 31];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Affiche le QR code pour l'OTP
|
|
*/
|
|
private displayOTPQRCode(secret: string): void {
|
|
try {
|
|
// Créer l'URL pour le QR code (format standard TOTP)
|
|
const issuer = 'LeCoffre';
|
|
const account = 'LeCoffre Security';
|
|
const qrUrl = `otpauth://totp/${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`;
|
|
|
|
console.log('🔐 OTP QR Code URL:', qrUrl);
|
|
console.log('🔐 Manual secret:', secret);
|
|
|
|
// Afficher une alerte avec les instructions
|
|
alert(`🔐 Configuration OTP terminée !
|
|
|
|
Secret OTP: ${secret}
|
|
|
|
Instructions:
|
|
1. Ouvrez Proton Pass ou votre application OTP
|
|
2. Ajoutez un nouveau compte
|
|
3. Scannez le QR code ou saisissez le secret manuellement
|
|
4. Utilisez le code OTP généré pour accéder à vos clés
|
|
|
|
QR Code URL: ${qrUrl}`);
|
|
|
|
secureLogger.info('OTP QR code displayed', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'displayOTPQRCode'
|
|
});
|
|
} catch (error) {
|
|
secureLogger.error('Failed to display OTP QR code', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'displayOTPQRCode'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Supprime les credentials
|
|
*/
|
|
async clearCredentials(): Promise<void> {
|
|
try {
|
|
// Import dynamique du service de stockage
|
|
const { StorageService } = await import('./credentials/storage.service');
|
|
const storageService = StorageService.getInstance();
|
|
|
|
await storageService.clearCredentials();
|
|
secureLogger.info('Credentials cleared successfully', {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'clearCredentials'
|
|
});
|
|
} catch (error) {
|
|
secureLogger.error('Failed to clear credentials', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'clearCredentials'
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Supprime les credentials (alias pour deleteCredentials)
|
|
*/
|
|
async deleteCredentials(): Promise<void> {
|
|
return this.clearCredentials();
|
|
}
|
|
|
|
/**
|
|
* Demande un mot de passe à l'utilisateur
|
|
*/
|
|
private async promptForPassword(): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
// Créer une interface utilisateur pour saisir le mot de passe
|
|
const modal = document.createElement('div');
|
|
modal.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10000;
|
|
`;
|
|
|
|
const dialog = document.createElement('div');
|
|
dialog.style.cssText = `
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
|
max-width: 400px;
|
|
width: 90%;
|
|
`;
|
|
|
|
dialog.innerHTML = `
|
|
<h3 style="margin: 0 0 20px 0; color: #333;">🔐 Mot de passe de sécurité</h3>
|
|
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">
|
|
Entrez un mot de passe fort pour chiffrer votre clé PBKDF2.<br>
|
|
<strong>Attention :</strong> Ce mot de passe ne sera pas sauvegardé et ne pourra pas être récupéré !
|
|
</p>
|
|
<input type="password" id="passwordInput" placeholder="Mot de passe"
|
|
style="width: 100%; padding: 12px; border: 2px solid #e1e5e9; border-radius: 6px; margin-bottom: 15px; font-size: 16px;">
|
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
|
<button id="cancelBtn" style="padding: 10px 20px; border: 1px solid #ccc; background: white; border-radius: 6px; cursor: pointer;">
|
|
Annuler
|
|
</button>
|
|
<button id="confirmBtn" style="padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 6px; cursor: pointer;">
|
|
Confirmer
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
modal.appendChild(dialog);
|
|
document.body.appendChild(modal);
|
|
|
|
const passwordInput = dialog.querySelector('#passwordInput') as HTMLInputElement;
|
|
const cancelBtn = dialog.querySelector('#cancelBtn') as HTMLButtonElement;
|
|
const confirmBtn = dialog.querySelector('#confirmBtn') as HTMLButtonElement;
|
|
|
|
// Focus sur l'input
|
|
passwordInput.focus();
|
|
|
|
// Gestion des événements
|
|
let cleanup = () => {
|
|
document.body.removeChild(modal);
|
|
};
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
cleanup();
|
|
reject(new Error('Password prompt cancelled'));
|
|
});
|
|
|
|
confirmBtn.addEventListener('click', () => {
|
|
const password = passwordInput.value.trim();
|
|
if (password.length < 8) {
|
|
alert('Le mot de passe doit contenir au moins 8 caractères');
|
|
passwordInput.focus();
|
|
return;
|
|
}
|
|
cleanup();
|
|
resolve(password);
|
|
});
|
|
|
|
// Gestion de la touche Entrée
|
|
passwordInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
confirmBtn.click();
|
|
}
|
|
});
|
|
|
|
// Gestion de la touche Échap
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
cleanup();
|
|
reject(new Error('Password prompt cancelled'));
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
// Nettoyer l'event listener quand le modal est fermé
|
|
const originalCleanup = cleanup;
|
|
cleanup = () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
originalCleanup();
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Demande un mot de passe à l'utilisateur en utilisant l'API Credential Management du navigateur
|
|
*/
|
|
private async promptForPasswordWithBrowser(): Promise<string> {
|
|
// Vérifier si l'API Credential Management est disponible
|
|
if (!navigator.credentials) {
|
|
console.warn('⚠️ Credential Management API not available, falling back to modal');
|
|
return this.promptForPassword();
|
|
}
|
|
|
|
try {
|
|
// Essayer de récupérer un mot de passe existant
|
|
// @ts-ignore - PasswordCredential API may not be in TypeScript definitions
|
|
const existingCredential = await navigator.credentials.get({
|
|
password: true,
|
|
mediation: 'optional'
|
|
} as any);
|
|
|
|
if (existingCredential?.type === 'password') {
|
|
// @ts-ignore - PasswordCredential API may not be in TypeScript definitions
|
|
const passwordCredential = existingCredential as any;
|
|
console.log('🔐 Retrieved existing password from browser');
|
|
return passwordCredential.password;
|
|
}
|
|
} catch (error) {
|
|
console.log('🔐 No existing password found, will create new one');
|
|
}
|
|
|
|
// Si aucun mot de passe existant, créer un nouveau
|
|
return new Promise((resolve, reject) => {
|
|
const modal = document.createElement('div');
|
|
modal.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10000;
|
|
`;
|
|
|
|
const dialog = document.createElement('div');
|
|
dialog.style.cssText = `
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
|
max-width: 400px;
|
|
width: 90%;
|
|
`;
|
|
|
|
dialog.innerHTML = `
|
|
<h3 style="margin: 0 0 20px 0; color: #333;">🔐 Mot de passe de sécurité</h3>
|
|
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">
|
|
Entrez un mot de passe fort pour chiffrer votre clé PBKDF2.<br>
|
|
<strong>Le navigateur vous proposera de sauvegarder ce mot de passe.</strong>
|
|
</p>
|
|
<input type="password" id="passwordInput" placeholder="Mot de passe"
|
|
style="width: 100%; padding: 12px; border: 2px solid #e1e5e9; border-radius: 6px; margin-bottom: 15px; font-size: 16px;">
|
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
|
<button id="cancelBtn" style="padding: 10px 20px; border: 1px solid #ccc; background: white; border-radius: 6px; cursor: pointer;">
|
|
Annuler
|
|
</button>
|
|
<button id="confirmBtn" style="padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 6px; cursor: pointer;">
|
|
Confirmer
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
modal.appendChild(dialog);
|
|
document.body.appendChild(modal);
|
|
|
|
const passwordInput = dialog.querySelector('#passwordInput') as HTMLInputElement;
|
|
const cancelBtn = dialog.querySelector('#cancelBtn') as HTMLButtonElement;
|
|
const confirmBtn = dialog.querySelector('#confirmBtn') as HTMLButtonElement;
|
|
|
|
passwordInput.focus();
|
|
|
|
let cleanup = () => {
|
|
document.body.removeChild(modal);
|
|
};
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
cleanup();
|
|
reject(new Error('Password prompt cancelled'));
|
|
});
|
|
|
|
confirmBtn.addEventListener('click', async () => {
|
|
const password = passwordInput.value.trim();
|
|
if (password.length < 8) {
|
|
alert('Le mot de passe doit contenir au moins 8 caractères');
|
|
passwordInput.focus();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Sauvegarder le mot de passe dans le gestionnaire de mots de passe du navigateur
|
|
// @ts-ignore - PasswordCredential API may not be in TypeScript definitions
|
|
if (typeof PasswordCredential !== 'undefined' && navigator.credentials && navigator.credentials.create) {
|
|
// @ts-ignore - PasswordCredential API may not be in TypeScript definitions
|
|
const credential = new PasswordCredential({
|
|
id: '4nk-pbkdf2-password',
|
|
password: password,
|
|
name: '4NK PBKDF2 Password',
|
|
iconURL: '/favicon.ico'
|
|
});
|
|
|
|
await navigator.credentials.store(credential);
|
|
console.log('🔐 Password saved to browser password manager');
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ Failed to save password to browser:', error);
|
|
// Continuer même si la sauvegarde échoue
|
|
}
|
|
|
|
cleanup();
|
|
resolve(password);
|
|
});
|
|
|
|
// Gestion de la touche Entrée
|
|
passwordInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
confirmBtn.click();
|
|
}
|
|
});
|
|
|
|
// Gestion de la touche Échap
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
cleanup();
|
|
reject(new Error('Password prompt cancelled'));
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
// Nettoyer l'event listener quand le modal est fermé
|
|
const originalCleanup = cleanup;
|
|
cleanup = () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
originalCleanup();
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Déchiffre une clé PBKDF2 avec le mot de passe du navigateur
|
|
*/
|
|
async decryptPBKDF2KeyWithPassword(encryptedKey: string): Promise<string | null> {
|
|
try {
|
|
// Récupérer le mot de passe depuis le gestionnaire de mots de passe du navigateur
|
|
const password = await this.getPasswordFromBrowser();
|
|
if (!password) {
|
|
console.warn('⚠️ No password found in browser, falling back to manual input');
|
|
return null;
|
|
}
|
|
|
|
// Déchiffrer avec le mot de passe
|
|
const { EncryptionService } = await import('./encryption.service');
|
|
const encryptionService = EncryptionService.getInstance();
|
|
return await encryptionService.decrypt(encryptedKey, password);
|
|
} catch (error) {
|
|
secureLogger.error('Failed to decrypt PBKDF2 key with password', error as Error, {
|
|
component: 'SecureCredentialsService',
|
|
operation: 'decryptPBKDF2KeyWithPassword'
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère le mot de passe depuis le gestionnaire de mots de passe du navigateur
|
|
*/
|
|
private async getPasswordFromBrowser(): Promise<string | null> {
|
|
// Vérifier si l'API Credential Management est disponible
|
|
if (!navigator.credentials) {
|
|
console.warn('⚠️ Credential Management API not available');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// @ts-ignore - PasswordCredential API may not be in TypeScript definitions
|
|
const credential = await navigator.credentials.get({
|
|
password: true,
|
|
mediation: 'optional'
|
|
} as any);
|
|
|
|
if (credential?.type === 'password') {
|
|
// @ts-ignore - PasswordCredential API may not be in TypeScript definitions
|
|
const passwordCredential = credential as any;
|
|
console.log('🔐 Retrieved password from browser password manager');
|
|
return passwordCredential.password;
|
|
}
|
|
} catch (error) {
|
|
console.log('🔐 No password found in browser password manager');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Valide la force d'un mot de passe
|
|
*/
|
|
validatePasswordStrength(password: string): {
|
|
isValid: boolean;
|
|
score: number;
|
|
feedback: string[];
|
|
} {
|
|
const feedback: string[] = [];
|
|
let score = 0;
|
|
|
|
// Vérifications de complexité
|
|
if (password.length < 8) {
|
|
feedback.push('Le mot de passe doit contenir au moins 8 caractères');
|
|
return { isValid: false, score: 0, feedback };
|
|
}
|
|
|
|
if (password.length >= 8) {score += 1;}
|
|
if (password.length >= 12) {score += 1;}
|
|
if (password.length >= 16) {score += 1;}
|
|
|
|
if (/[a-z]/.test(password)) {score += 1;}
|
|
if (/[A-Z]/.test(password)) {score += 1;}
|
|
if (/[0-9]/.test(password)) {score += 1;}
|
|
if (/[^a-zA-Z0-9]/.test(password)) {score += 1;}
|
|
|
|
// Feedback détaillé
|
|
if (score < 3) {
|
|
feedback.push('Mot de passe faible : ajoutez des majuscules, chiffres et caractères spéciaux');
|
|
} else if (score < 5) {
|
|
feedback.push('Mot de passe moyen : renforcez avec plus de complexité');
|
|
}
|
|
|
|
const isValid = score >= 3;
|
|
|
|
return { isValid, score, feedback };
|
|
}
|
|
}
|
|
|
|
// Export instance pour compatibilité
|
|
export const secureCredentialsService = SecureCredentialsService.getInstance(); |