**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)
599 lines
18 KiB
TypeScript
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();
|