ihm_client/src/services/secure-credentials.service.ts
NicolasCantu 066580f8d6 fix: Déclencher WebAuthn directement lors du clic utilisateur
- Déplacer l'appel WebAuthn dans le gestionnaire de clic direct
- Ajouter logs de debugging pour WebAuthn availability
- Éviter les appels WebAuthn asynchrones qui ne sont pas considérés comme user gesture
- Améliorer les messages d'interface pour l'authentification
- Supprimer l'appel WebAuthn dupliqué dans prepareAndSendPairingTx
2025-10-23 14:09:18 +02:00

525 lines
15 KiB
TypeScript

/**
* SecureCredentialsService - Gestion sécurisée des credentials avec PBKDF2
* Utilise les credentials du navigateur pour sécuriser les clés de spend et de scan
*/
import { secureLogger } from './secure-logger';
export interface CredentialData {
spendKey: string;
scanKey: string;
salt: Uint8Array;
iterations: number;
timestamp: number;
}
export interface CredentialOptions {
iterations?: number;
saltLength?: number;
keyLength?: number;
}
export class SecureCredentialsService {
private static instance: SecureCredentialsService;
private readonly defaultOptions: Required<CredentialOptions> = {
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 PBKDF2
*/
async generateSecureCredentials(
password: string,
options: CredentialOptions = {}
): Promise<CredentialData> {
try {
const opts = { ...this.defaultOptions, ...options };
secureLogger.info('Generating secure credentials with PBKDF2', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials',
iterations: opts.iterations
});
// Générer un salt aléatoire
const salt = crypto.getRandomValues(new Uint8Array(opts.saltLength));
// Dériver la clé maître avec PBKDF2
const masterKey = await this.deriveMasterKey(password, salt, opts.iterations);
// Générer les clés spécifiques
const spendKey = await this.deriveSpendKey(masterKey, salt);
const scanKey = await this.deriveScanKey(masterKey, salt);
const credentialData: CredentialData = {
spendKey,
scanKey,
salt,
iterations: opts.iterations,
timestamp: Date.now()
};
secureLogger.info('Secure credentials generated successfully', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials',
hasSpendKey: !!spendKey,
hasScanKey: !!scanKey
});
return credentialData;
} catch (error) {
secureLogger.error('Failed to generate secure credentials', error as Error, {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials'
});
throw error;
}
}
/**
* Stocke les credentials de manière sécurisée
*/
async storeCredentials(
credentialData: CredentialData,
password: string
): Promise<void> {
try {
// Chiffrer les clés avec la clé maître
const masterKey = await this.deriveMasterKey(
password,
credentialData.salt,
credentialData.iterations
);
const encryptedSpendKey = await this.encryptKey(credentialData.spendKey, masterKey);
const encryptedScanKey = await this.encryptKey(credentialData.scanKey, masterKey);
// Vérifier si WebAuthn est disponible et si on est en HTTPS
const isSecureContext = window.isSecureContext;
const hasWebAuthn = navigator.credentials && navigator.credentials.create;
secureLogger.info('WebAuthn availability check', {
component: 'SecureCredentialsService',
operation: 'webauthn_check',
isSecureContext,
hasWebAuthn,
userAgent: navigator.userAgent,
protocol: window.location.protocol
});
let credential = null;
if (isSecureContext && hasWebAuthn) {
// Stocker dans les credentials du navigateur (HTTPS requis)
try {
secureLogger.info('Attempting to create WebAuthn credential', {
component: 'SecureCredentialsService',
operation: 'webauthn_create_attempt'
});
credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(32),
rp: { name: '4NK Secure Storage' },
user: {
id: new TextEncoder().encode('4nk-user'),
name: '4NK User',
displayName: '4NK User'
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required'
},
timeout: 60000,
attestation: 'direct'
}
});
secureLogger.info('WebAuthn credential created successfully', {
component: 'SecureCredentialsService',
operation: 'webauthn_create'
});
} catch (error) {
secureLogger.warn('WebAuthn credential creation failed, using fallback', error as Error, {
component: 'SecureCredentialsService',
operation: 'webauthn_create'
});
}
} else {
secureLogger.info('WebAuthn not available (HTTP context), using fallback storage', {
component: 'SecureCredentialsService',
operation: 'webauthn_fallback',
isSecureContext,
hasWebAuthn
});
}
if (credential) {
// Stocker les données chiffrées dans IndexedDB
await this.storeEncryptedCredentials({
encryptedSpendKey,
encryptedScanKey,
salt: credentialData.salt,
iterations: credentialData.iterations,
timestamp: credentialData.timestamp,
credentialId: credential.id
});
secureLogger.info('Credentials stored securely', {
component: 'SecureCredentialsService',
operation: 'storeCredentials',
credentialId: credential.id
});
}
} catch (error) {
secureLogger.error('Failed to store credentials', error as Error, {
component: 'SecureCredentialsService',
operation: 'storeCredentials'
});
throw error;
}
}
/**
* Récupère et déchiffre les credentials
*/
async retrieveCredentials(password: string): Promise<CredentialData | null> {
try {
// Récupérer les données chiffrées
const encryptedData = await this.getEncryptedCredentials();
if (!encryptedData) {
return null;
}
// Dériver la clé maître
const masterKey = await this.deriveMasterKey(
password,
encryptedData.salt,
encryptedData.iterations
);
// Déchiffrer les clés
const spendKey = await this.decryptKey(encryptedData.encryptedSpendKey, masterKey);
const scanKey = await this.decryptKey(encryptedData.encryptedScanKey, masterKey);
const credentialData: CredentialData = {
spendKey,
scanKey,
salt: encryptedData.salt,
iterations: encryptedData.iterations,
timestamp: encryptedData.timestamp
};
secureLogger.info('Credentials retrieved and decrypted', {
component: 'SecureCredentialsService',
operation: 'retrieveCredentials',
hasSpendKey: !!spendKey,
hasScanKey: !!scanKey
});
return credentialData;
} catch (error) {
secureLogger.error('Failed to retrieve credentials', error as Error, {
component: 'SecureCredentialsService',
operation: 'retrieveCredentials'
});
return null;
}
}
/**
* Vérifie si des credentials existent
*/
async hasCredentials(): Promise<boolean> {
try {
const encryptedData = await this.getEncryptedCredentials();
return encryptedData !== null;
} catch (error) {
secureLogger.error('Failed to check credentials existence', error as Error, {
component: 'SecureCredentialsService',
operation: 'hasCredentials'
});
return false;
}
}
/**
* Supprime les credentials
*/
async deleteCredentials(): Promise<void> {
try {
await this.clearEncryptedCredentials();
secureLogger.info('Credentials deleted', {
component: 'SecureCredentialsService',
operation: 'deleteCredentials'
});
} catch (error) {
secureLogger.error('Failed to delete credentials', error as Error, {
component: 'SecureCredentialsService',
operation: 'deleteCredentials'
});
throw error;
}
}
/**
* Dérive la clé maître avec PBKDF2
*/
private async deriveMasterKey(
password: string,
salt: Uint8Array,
iterations: number
): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: iterations,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true, // Make key extractable
['encrypt', 'decrypt']
);
}
/**
* Dérive la clé de spend
*/
private async deriveSpendKey(masterKey: CryptoKey, salt: Uint8Array): Promise<string> {
const spendSalt = new Uint8Array([...salt, 0x73, 0x70, 0x65, 0x6e, 0x64]); // "spend"
// Use HMAC with the master key to derive spend key
const hmacKey = await crypto.subtle.importKey(
'raw',
await crypto.subtle.exportKey('raw', masterKey),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const spendKeyMaterial = await crypto.subtle.sign(
'HMAC',
hmacKey,
spendSalt
);
return Array.from(new Uint8Array(spendKeyMaterial))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('');
}
/**
* Dérive la clé de scan
*/
private async deriveScanKey(masterKey: CryptoKey, salt: Uint8Array): Promise<string> {
const scanSalt = new Uint8Array([...salt, 0x73, 0x63, 0x61, 0x6e]); // "scan"
// Use HMAC with the master key to derive scan key
const hmacKey = await crypto.subtle.importKey(
'raw',
await crypto.subtle.exportKey('raw', masterKey),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const scanKeyMaterial = await crypto.subtle.sign(
'HMAC',
hmacKey,
scanSalt
);
return Array.from(new Uint8Array(scanKeyMaterial))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('');
}
/**
* Chiffre une clé avec AES-GCM
*/
private async encryptKey(key: string, masterKey: CryptoKey): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
masterKey,
new TextEncoder().encode(key)
);
// Combiner IV + données chiffrées
const result = new Uint8Array(iv.length + encrypted.byteLength);
result.set(iv);
result.set(new Uint8Array(encrypted), iv.length);
return result;
}
/**
* Déchiffre une clé avec AES-GCM
*/
private async decryptKey(encryptedData: Uint8Array, masterKey: CryptoKey): Promise<string> {
const iv = encryptedData.slice(0, 12);
const encrypted = encryptedData.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
masterKey,
encrypted
);
return new TextDecoder().decode(decrypted);
}
/**
* Stocke les credentials chiffrés dans IndexedDB
*/
private async storeEncryptedCredentials(data: any): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('SecureCredentials', 1);
request.onerror = () => reject(new Error('Failed to open IndexedDB'));
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['credentials'], 'readwrite');
const store = transaction.objectStore('credentials');
const putRequest = store.put(data, 'secure-credentials');
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(new Error('Failed to store encrypted credentials'));
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('credentials')) {
db.createObjectStore('credentials');
}
};
});
}
/**
* Récupère les credentials chiffrés depuis IndexedDB
*/
private async getEncryptedCredentials(): Promise<any | null> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('SecureCredentials', 1);
request.onerror = () => reject(new Error('Failed to open IndexedDB'));
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['credentials'], 'readonly');
const store = transaction.objectStore('credentials');
const getRequest = store.get('secure-credentials');
getRequest.onsuccess = () => resolve(getRequest.result);
getRequest.onerror = () => reject(new Error('Failed to retrieve encrypted credentials'));
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('credentials')) {
db.createObjectStore('credentials');
}
};
});
}
/**
* Supprime les credentials chiffrés
*/
private async clearEncryptedCredentials(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('SecureCredentials', 1);
request.onerror = () => reject(new Error('Failed to open IndexedDB'));
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['credentials'], 'readwrite');
const store = transaction.objectStore('credentials');
const deleteRequest = store.delete('secure-credentials');
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(new Error('Failed to clear encrypted credentials'));
};
});
}
/**
* 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) {
feedback.push('Le mot de passe doit contenir au moins 8 caractères');
} else {
score += 1;
}
if (!/[A-Z]/.test(password)) {
feedback.push('Le mot de passe doit contenir au moins une majuscule');
} else {
score += 1;
}
if (!/[a-z]/.test(password)) {
feedback.push('Le mot de passe doit contenir au moins une minuscule');
} else {
score += 1;
}
if (!/[0-9]/.test(password)) {
feedback.push('Le mot de passe doit contenir au moins un chiffre');
} else {
score += 1;
}
if (!/[^A-Za-z0-9]/.test(password)) {
feedback.push('Le mot de passe doit contenir au moins un caractère spécial');
} else {
score += 1;
}
return {
isValid: score >= 4,
score,
feedback
};
}
}
// Instance singleton pour l'application
export const secureCredentialsService = SecureCredentialsService.getInstance();