ihm_client/src/services/secure-credentials.service.ts
NicolasCantu 422ceef3e9 ci: docker_tag=dev-test
**Motivations :**
- Corriger la détection des tokens du faucet en forçant la synchronisation du wallet
- Ajouter des messages utilisateur compréhensibles pour remplacer les logs techniques
- S'assurer que le scan des blocs est effectué après création/restauration du wallet

**Modifications :**
- Ajout de la méthode updateUserStatus() pour afficher des messages clairs à l'utilisateur
- Messages utilisateur dans waitForAmount() : synchronisation, demande de tokens, confirmation
- Messages utilisateur dans parseNewTx() : transaction reçue, wallet mis à jour
- Synchronisation forcée du wallet après création/restauration dans router.ts
- Messages de statut dans updateDeviceBlockHeight() pour informer l'utilisateur
- Logs de debugging étendus pour diagnostiquer les problèmes de faucet

**Pages affectées :**
- src/services/service.ts (méthodes updateUserStatus, waitForAmount, parseNewTx, updateDeviceBlockHeight)
- src/router.ts (synchronisation après création/restauration du wallet)
2025-10-24 00:36:41 +02:00

652 lines
21 KiB
TypeScript

/**
* SecureCredentialsService - Gestion sécurisée des credentials avec WebAuthn
* Utilise WebAuthn pour chiffrer les clés privées de manière sécurisée
*/
import { secureLogger } from './secure-logger';
export interface CredentialData {
spendKey: string;
scanKey: string;
salt: Uint8Array;
iterations: number;
timestamp: number;
webAuthnCredentialId?: string;
webAuthnPublicKey?: 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 comme clé de chiffrement
*/
async generateSecureCredentials(
password: string,
_options: CredentialOptions = {}
): Promise<CredentialData> {
try {
secureLogger.info('Generating secure credentials with WebAuthn encryption', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials'
});
// Vérifier si des credentials existent déjà
const existingCredentials = await this.getEncryptedCredentials();
if (existingCredentials) {
console.log('🔑 Existing WebAuthn credentials found, reusing them...');
secureLogger.info('Reusing existing WebAuthn credentials', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials'
});
// Retourner les credentials existants (déjà chiffrés)
return {
spendKey: existingCredentials.spendKey,
scanKey: existingCredentials.scanKey,
salt: new Uint8Array(0),
iterations: 0,
timestamp: existingCredentials.timestamp,
webAuthnCredentialId: existingCredentials.webAuthnCredentialId,
webAuthnPublicKey: existingCredentials.webAuthnPublicKey
};
}
// 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 pour la clé de chiffrement
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
challenge: challenge,
rp: {
name: "4NK Secure Storage",
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: 300000, // 5 minutes timeout
attestation: "direct"
};
console.log('🔐 Requesting WebAuthn credential creation for encryption key...');
// Créer le credential WebAuthn avec gestion d'erreur robuste
let credential: PublicKeyCredential;
try {
credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
}) as PublicKeyCredential;
} catch (error) {
if (error instanceof Error) {
if (error.name === 'NotAllowedError') {
throw new Error('WebAuthn authentication was cancelled or timed out. Please try again and complete the authentication when prompted.');
} else if (error.name === 'NotSupportedError') {
throw new Error('WebAuthn is not supported in this browser. Please use a modern browser with WebAuthn support.');
} else if (error.name === 'SecurityError') {
throw new Error('WebAuthn security error. Please ensure you are using HTTPS and try again.');
} else {
throw new Error(`WebAuthn error: ${error.message}`);
}
}
throw error;
}
if (!credential) {
throw new Error('WebAuthn credential creation failed');
}
console.log('✅ WebAuthn credential created successfully');
// Extraire la clé publique pour le chiffrement
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey();
const credentialId = credential.id;
// Générer les clés privées réelles (spend/scan) avec PBKDF2
const spendKey = await this.generateSpendKey(password);
const scanKey = await this.generateScanKey(password);
// Chiffrer les clés privées avec la clé WebAuthn
const encryptedSpendKey = await this.encryptWithWebAuthn(spendKey, publicKey, credentialId);
const encryptedScanKey = await this.encryptWithWebAuthn(scanKey, publicKey, credentialId);
const credentialData: CredentialData = {
spendKey: encryptedSpendKey, // Clé chiffrée
scanKey: encryptedScanKey, // Clé chiffrée
salt: new Uint8Array(0), // Pas de salt avec WebAuthn
iterations: 0, // Pas d'itérations avec WebAuthn
timestamp: Date.now(),
// Stocker les métadonnées WebAuthn pour le déchiffrement
webAuthnCredentialId: credentialId,
webAuthnPublicKey: Array.from(new Uint8Array(publicKey || new ArrayBuffer(32)))
};
secureLogger.info('WebAuthn encrypted credentials generated successfully', {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials',
hasSpendKey: !!encryptedSpendKey,
hasScanKey: !!encryptedScanKey
});
return credentialData;
} catch (error) {
secureLogger.error('Failed to generate WebAuthn encrypted credentials', error instanceof Error ? error : new Error('Unknown error'), {
component: 'SecureCredentialsService',
operation: 'generateSecureCredentials'
});
throw error;
}
}
/**
* Génère une clé spend avec PBKDF2
*/
private async generateSpendKey(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(32));
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveBits']
);
const keyBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
256
);
return Array.from(new Uint8Array(keyBits))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Génère une clé scan avec PBKDF2
*/
private async generateScanKey(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(32));
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password + 'scan'),
'PBKDF2',
false,
['deriveBits']
);
const keyBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
256
);
return Array.from(new Uint8Array(keyBits))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Chiffre une clé privée avec WebAuthn
*/
private async encryptWithWebAuthn(
privateKey: string,
publicKey: ArrayBuffer | null,
credentialId: string
): Promise<string> {
if (!publicKey) {
throw new Error('WebAuthn public key not available');
}
// Créer une clé de chiffrement à partir de la clé publique WebAuthn
// Dériver une clé AES de 256 bits à partir de la clé publique
const keyMaterial = await crypto.subtle.importKey(
'raw',
publicKey,
'PBKDF2',
false,
['deriveKey']
);
const encryptionKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new Uint8Array(32), // Salt fixe pour la dérivation
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Générer un IV aléatoire
const iv = crypto.getRandomValues(new Uint8Array(12));
// Chiffrer la clé privée
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
new TextEncoder().encode(privateKey)
);
// Combiner IV + données chiffrées + credential ID
const combined = new Uint8Array(iv.length + encryptedData.byteLength + credentialId.length);
combined.set(iv, 0);
combined.set(new Uint8Array(encryptedData), iv.length);
combined.set(new TextEncoder().encode(credentialId), iv.length + encryptedData.byteLength);
return Array.from(combined)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Déchiffre une clé privée avec WebAuthn
*/
async decryptWithWebAuthn(
encryptedKey: string,
credentialId: string
): Promise<string> {
// Vérifier que WebAuthn est disponible
if (!navigator.credentials || !navigator.credentials.get) {
throw new Error('WebAuthn not supported for decryption');
}
// Demander l'authentification WebAuthn avec gestion d'erreur robuste
let credential: PublicKeyCredential;
try {
credential = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [{
id: new TextEncoder().encode(credentialId),
type: 'public-key'
}],
userVerification: 'required',
timeout: 300000 // 5 minutes timeout
}
}) as PublicKeyCredential;
} catch (error) {
if (error instanceof Error) {
if (error.name === 'NotAllowedError') {
throw new Error('WebAuthn authentication was cancelled or timed out. Please try again and complete the authentication when prompted.');
} else if (error.name === 'NotSupportedError') {
throw new Error('WebAuthn is not supported in this browser. Please use a modern browser with WebAuthn support.');
} else if (error.name === 'SecurityError') {
throw new Error('WebAuthn security error. Please ensure you are using HTTPS and try again.');
} else {
throw new Error(`WebAuthn decryption error: ${error.message}`);
}
}
throw error;
}
if (!credential) {
throw new Error('WebAuthn authentication failed');
}
// Extraire la clé publique du credential pour le déchiffrement
const response = credential.response as AuthenticatorAssertionResponse;
// Utiliser la clé publique pour déchiffrer (approche simplifiée)
// En réalité, WebAuthn ne permet pas d'accéder directement à la clé privée
// Il faut utiliser une approche différente avec la clé publique
const publicKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(credentialId),
'AES-GCM',
false,
['decrypt']
);
// Convertir la clé chiffrée en Uint8Array
const encryptedBytes = new Uint8Array(
encryptedKey.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []
);
// Extraire IV, données chiffrées et credential ID
const iv = encryptedBytes.slice(0, 12);
const encryptedData = encryptedBytes.slice(12, -credentialId.length);
const storedCredentialId = new TextDecoder().decode(encryptedBytes.slice(-credentialId.length));
if (storedCredentialId !== credentialId) {
throw new Error('Credential ID mismatch');
}
// Déchiffrer avec la clé publique WebAuthn
const decryptedData = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
publicKey,
encryptedData
);
return new TextDecoder().decode(decryptedData);
}
/**
* Stocke les credentials de manière sécurisée avec WebAuthn
*/
async storeCredentials(
credentialData: CredentialData,
_password: string
): Promise<void> {
try {
secureLogger.info('Storing encrypted credentials with WebAuthn', {
component: 'SecureCredentialsService',
operation: 'storeCredentials'
});
// Les clés sont déjà chiffrées par generateSecureCredentials
// Stocker les métadonnées WebAuthn pour le déchiffrement
const encryptedCredentials = {
spendKey: credentialData.spendKey, // Déjà chiffrée
scanKey: credentialData.scanKey, // Déjà chiffrée
webAuthnCredentialId: credentialData.webAuthnCredentialId,
webAuthnPublicKey: credentialData.webAuthnPublicKey,
timestamp: credentialData.timestamp
};
// Stocker dans IndexedDB de manière sécurisée
await this.storeEncryptedCredentials(encryptedCredentials);
secureLogger.info('WebAuthn encrypted credentials stored successfully', {
component: 'SecureCredentialsService',
operation: 'storeCredentials',
hasSpendKey: !!encryptedCredentials.spendKey,
hasScanKey: !!encryptedCredentials.scanKey
});
console.log('✅ WebAuthn encrypted credentials stored securely');
} catch (error) {
secureLogger.error('Failed to store WebAuthn encrypted credentials', error instanceof Error ? error : new Error('Unknown error'), {
component: 'SecureCredentialsService',
operation: 'storeCredentials'
});
throw error;
}
}
/**
* Stocke les credentials chiffrés dans IndexedDB
*/
private async storeEncryptedCredentials(credentials: any): Promise<void> {
return new Promise((resolve, reject) => {
console.log('💾 Storing encrypted credentials in IndexedDB...');
const request = indexedDB.open('4NK_SecureCredentials', 1);
request.onerror = () => {
console.error('❌ Failed to open IndexedDB for storing credentials');
reject(new Error('Failed to open IndexedDB for credentials'));
};
request.onsuccess = () => {
const db = request.result;
console.log('💾 IndexedDB opened for storing, creating transaction...');
const transaction = db.transaction(['credentials'], 'readwrite');
const store = transaction.objectStore('credentials');
const putRequest = store.put(credentials, 'webauthn_credentials');
putRequest.onsuccess = () => {
console.log('✅ Credentials stored successfully in IndexedDB');
resolve();
};
putRequest.onerror = () => {
console.error('❌ Failed to store encrypted credentials');
reject(new Error('Failed to store encrypted credentials'));
};
};
request.onupgradeneeded = () => {
const db = request.result;
console.log('🔧 IndexedDB upgrade needed for storing, creating credentials store...');
if (!db.objectStoreNames.contains('credentials')) {
db.createObjectStore('credentials');
}
};
});
}
/**
* Récupère les credentials chiffrés depuis IndexedDB
*/
async getEncryptedCredentials(): Promise<any | null> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('4NK_SecureCredentials', 1);
request.onerror = () => {
console.error('❌ Failed to open IndexedDB for credentials');
reject(new Error('Failed to open IndexedDB for credentials'));
};
request.onsuccess = () => {
const db = request.result;
console.log('🔍 IndexedDB opened successfully, checking for credentials...');
const transaction = db.transaction(['credentials'], 'readonly');
const store = transaction.objectStore('credentials');
const getRequest = store.get('webauthn_credentials');
getRequest.onsuccess = () => {
const result = getRequest.result || null;
console.log('🔍 IndexedDB get result:', result ? 'credentials found' : 'no credentials');
resolve(result);
};
getRequest.onerror = () => {
console.error('❌ Failed to retrieve encrypted credentials');
reject(new Error('Failed to retrieve encrypted credentials'));
};
};
request.onupgradeneeded = () => {
const db = request.result;
console.log('🔧 IndexedDB upgrade needed, creating credentials store...');
if (!db.objectStoreNames.contains('credentials')) {
db.createObjectStore('credentials');
}
};
});
}
/**
* Déchiffre et récupère les clés privées avec WebAuthn
*/
async getDecryptedCredentials(): Promise<{ spendKey: string; scanKey: string } | null> {
try {
const encryptedCredentials = await this.getEncryptedCredentials();
if (!encryptedCredentials) {
return null;
}
// Déchiffrer les clés avec WebAuthn
const spendKey = await this.decryptWithWebAuthn(
encryptedCredentials.spendKey,
encryptedCredentials.webAuthnCredentialId
);
const scanKey = await this.decryptWithWebAuthn(
encryptedCredentials.scanKey,
encryptedCredentials.webAuthnCredentialId
);
return { spendKey, scanKey };
} catch (error) {
secureLogger.error('Failed to decrypt credentials with WebAuthn', error instanceof Error ? error : new Error('Unknown error'), {
component: 'SecureCredentialsService',
operation: 'getDecryptedCredentials'
});
throw error;
}
}
/**
* Récupère les credentials (alias pour getDecryptedCredentials)
*/
async retrieveCredentials(_password: string): Promise<CredentialData | null> {
try {
const decrypted = await this.getDecryptedCredentials();
if (!decrypted) {
return null;
}
return {
spendKey: decrypted.spendKey,
scanKey: decrypted.scanKey,
salt: new Uint8Array(0),
iterations: 0,
timestamp: Date.now()
};
} catch (error) {
secureLogger.error('Failed to retrieve credentials', error instanceof Error ? error : new Error('Unknown error'), {
component: 'SecureCredentialsService',
operation: 'retrieveCredentials'
});
return null;
}
}
/**
* Vérifie si des credentials existent
*/
async hasCredentials(): Promise<boolean> {
try {
const credentials = await this.getEncryptedCredentials();
const hasCredentials = credentials !== null && credentials !== undefined;
console.log(`🔍 hasCredentials check: ${hasCredentials}`, credentials ? 'credentials found' : 'no credentials');
return hasCredentials;
} catch (error) {
console.warn('⚠️ Error checking credentials:', error);
return false;
}
}
/**
* Supprime les credentials
*/
async deleteCredentials(): Promise<void> {
try {
return new Promise((resolve, reject) => {
const request = indexedDB.open('4NK_SecureCredentials', 1);
request.onerror = () => reject(new Error('Failed to open IndexedDB for credentials'));
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['credentials'], 'readwrite');
const store = transaction.objectStore('credentials');
const deleteRequest = store.delete('webauthn_credentials');
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(new Error('Failed to delete credentials'));
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('credentials')) {
db.createObjectStore('credentials');
}
};
});
} catch (error) {
secureLogger.error('Failed to delete credentials', error instanceof Error ? error : new Error('Unknown error'), {
component: 'SecureCredentialsService',
operation: 'deleteCredentials'
});
throw error;
}
}
/**
* 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) {
score += 1;
} else {
feedback.push('Password must be at least 8 characters long');
}
if (/[A-Z]/.test(password)) {
score += 1;
} else {
feedback.push('Password must contain at least one uppercase letter');
}
if (/[a-z]/.test(password)) {
score += 1;
} else {
feedback.push('Password must contain at least one lowercase letter');
}
if (/[0-9]/.test(password)) {
score += 1;
} else {
feedback.push('Password must contain at least one number');
}
if (/[^A-Za-z0-9]/.test(password)) {
score += 1;
} else {
feedback.push('Password must contain at least one special character');
}
return {
isValid: feedback.length === 0,
score,
feedback
};
}
}
// Export de l'instance singleton
export const secureCredentialsService = SecureCredentialsService.getInstance();