ihm_client/src/services/secure-credentials.service.ts
NicolasCantu e3e3d5431e feat: implement WebAuthn authentication for secure credentials
**Motivations :**
- Replace PBKDF2 with WebAuthn for browser-native authentication
- Enable secure credential storage using browser's built-in security
- Require user interaction for credential generation
- Store credentials in browser's credential manager

**Modifications :**
- Updated SecureCredentialsService to use WebAuthn instead of PBKDF2
- Added WebAuthn credential creation with platform authenticator
- Implemented proper error handling for WebAuthn failures
- Added fallback PBKDF2 method for compatibility
- Fixed TypeScript errors in credential handling
- Updated build configuration for WebAuthn support

**Pages affectées :**
- src/services/secure-credentials.service.ts (WebAuthn implementation)
- vite.config.ts (WebAssembly and plugin configuration)
- src/utils/sp-address.utils.ts (user interaction flow)
- Build system (TypeScript compilation fixes)
2025-10-23 16:47:22 +02:00

599 lines
18 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 WebAuthn
*/
async generateSecureCredentials(
password: string,
_options: CredentialOptions = {}
): Promise<CredentialData> {
try {
secureLogger.info('Generating secure credentials with WebAuthn', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials'
});
// 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
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
challenge: challenge,
rp: {
name: "4NK Pairing",
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: 60000,
attestation: "direct"
};
console.log('🔐 Requesting WebAuthn credential creation...');
// Créer le credential WebAuthn
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
}) as PublicKeyCredential;
if (!credential) {
throw new Error('WebAuthn credential creation failed');
}
console.log('✅ WebAuthn credential created successfully');
// Extraire les données du credential
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey();
const credentialId = credential.id;
// Générer les clés de chiffrement à partir du credential
const spendKey = Array.from(new Uint8Array(publicKey || new ArrayBuffer(32)))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const scanKey = Array.from(new Uint8Array(new TextEncoder().encode(credentialId)))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const credentialData: CredentialData = {
spendKey,
scanKey,
salt: new Uint8Array(0), // Pas de salt avec WebAuthn
iterations: 0, // Pas d'itérations avec WebAuthn
timestamp: Date.now()
};
secureLogger.info('WebAuthn credentials generated successfully', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials',
hasSpendKey: !!spendKey,
hasScanKey: !!scanKey
});
return credentialData;
} catch (error) {
secureLogger.error('Failed to generate WebAuthn credentials', error instanceof Error ? error : new Error('Unknown error'), {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials'
});
throw error;
}
}
/**
* Génère des credentials sécurisés avec PBKDF2 (fallback)
*/
async generateSecureCredentialsPBKDF2(
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);
// Forcer l'utilisation de WebAuthn (pas de fallback)
console.log('🚨🚨🚨 FORCING WEBAUTHN - NO FALLBACK 🚨🚨🚨');
console.log('🔥🔥🔥 NEW VERSION LOADED - NO FALLBACK ACTIVE 🔥🔥🔥');
console.log('🔍 DEBUG: Forcing WebAuthn credential creation');
console.log('🔄 VERSION: 2025-10-23-12:15 - NO FALLBACK ACTIVE');
console.log('🚀 CACHE-BUST: ' + Date.now() + ' - FORCING WEBAUTHN ONLY - RELOADED');
console.log('🌐 HTTPS VERSION: ' + window.location.href + ' - NO FALLBACK ACTIVE');
secureLogger.info('Forcing WebAuthn credential creation', {
component: 'SecureCredentialsService',
operation: 'webauthn_force'
});
const 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'
});
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: new Uint8Array(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 masterKeyRaw = await crypto.subtle.exportKey('raw', masterKey);
const hmacKey = await crypto.subtle.importKey(
'raw',
masterKeyRaw,
{ 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 masterKeyRaw = await crypto.subtle.exportKey('raw', masterKey);
const hmacKey = await crypto.subtle.importKey(
'raw',
masterKeyRaw,
{ 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();