- 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
525 lines
15 KiB
TypeScript
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();
|