import { useState, useEffect, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useIdentity } from '../hooks/useIdentity'; import { useErrorHandler } from '../hooks/useErrorHandler'; import { useLoginStateMachine } from '../hooks/useLoginStateMachine'; import { ErrorDisplay } from './ErrorDisplay'; import { LoginCollectShare } from './LoginCollectShare'; import { getStoredRelays } from '../utils/relay'; import { GraphResolver } from '../services/graphResolver'; import { LoginBuilder } from '../services/loginBuilder'; import { useChannel } from '../hooks/useChannel'; import { isInIframe, sendToChannel } from '../utils/iframeChannel'; import { isPairingSatisfied, getPairsForMember } from '../utils/pairing'; import { buildAllowedPubkeys, checkDependenciesSatisfied, collectProgress, hasRemoteSignatures, } from '../utils/loginValidation'; import { publishMessageAndSigs } from '../utils/loginPublish'; import { buildPairToMembers, buildPubkeyToPair, runCollectLoop, COLLECT_POLL_MS, COLLECT_TIMEOUT_MS, type ProofSignature, } from '../utils/collectSignatures'; import { verifyTimestamp, verifyMessageSignaturesStrict, } from '../utils/verification'; import * as nonceStore from '../utils/nonceStore'; import type { LoginPath, LoginProof } from '../types/identity'; import type { MessageBase } from '../types/message'; export function LoginScreen(): JSX.Element { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { identity } = useIdentity(); const { error, handleError, clearError } = useErrorHandler(); const { sendLoginProof } = useChannel(); const { state: loginState, dispatch } = useLoginStateMachine(); const serviceUuid = searchParams.get('service') ?? ''; const membreUuid = searchParams.get('membre') ?? ''; const [loginPath, setLoginPath] = useState(null); const [proof, setProof] = useState(null); const [isBuilding, setIsBuilding] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [isCollecting, setIsCollecting] = useState(false); const [awaitingRemoteAccept, setAwaitingRemoteAccept] = useState(false); const [collectedMerged, setCollectedMerged] = useState( null, ); const [collectedPublishStats, setCollectedPublishStats] = useState<{ successCount: number; relaysCount: number; relayStatus?: Array<{ endpoint: string; ok: boolean; error?: string }>; } | null>(null); const [publishRelayStatus, setPublishRelayStatus] = useState< Array<{ endpoint: string; ok: boolean; error?: string }> >([]); const [collectProgressState, setCollectProgressState] = useState<{ satisfied: number; required: number; } | null>(null); const graphResolver = useState(() => new GraphResolver())[0]; const pairingSatisfied = isPairingSatisfied(); const relayOk = getStoredRelays().length > 0; useEffect(() => { if (!isInIframe()) { return; } sendToChannel({ type: 'pairing-relay-status', payload: { pairingSatisfied, relayOk, }, }); }, [pairingSatisfied, relayOk]); // Rediriger selon l'état de la machine à états. Si service+membre en URL (parent iframe), // ne pas rediriger vers /services; dispatcher E_SELECT_* pour aller en S_LOGIN_BUILD_PATH. useEffect(() => { if (loginState === 'S_LOGIN_SELECT_SERVICE') { if (serviceUuid !== '' && membreUuid !== '') { dispatch({ type: 'E_SELECT_SERVICE', serviceUuid }); dispatch({ type: 'E_SELECT_MEMBER', membreUuid }); return; } navigate('/services'); return; } if (loginState === 'S_LOGIN_SELECT_MEMBER') { if (serviceUuid !== '') { navigate(`/select-member?service=${serviceUuid}`); return; } navigate('/services'); return; } }, [loginState, serviceUuid, membreUuid, navigate, dispatch]); const handleBuildPath = useCallback(async (): Promise => { if (serviceUuid === '' || membreUuid === '') { handleError('Service UUID et Membre UUID requis', 'MISSING_PARAMS'); return; } setIsBuilding(true); clearError(); dispatch({ type: 'E_SELECT_SERVICE', serviceUuid }); dispatch({ type: 'E_SELECT_MEMBER', membreUuid }); try { const path = graphResolver.resolveLoginPath(serviceUuid, membreUuid); if (path === null) { handleError('Impossible de résoudre le chemin de login', 'PATH_RESOLUTION_FAILED'); dispatch({ type: 'E_PATH_INVALID' }); return; } setLoginPath(path); if (path.statut === 'incomplet') { handleError('Chemin incomplet. Synchronisez d\'abord les données.', 'INCOMPLETE_PATH'); dispatch({ type: 'E_PATH_INCOMPLETE' }); } else { dispatch({ type: 'E_PATH_OK' }); dispatch({ type: 'E_PAIRS_OK' }); } } catch (err) { handleError(err, 'Erreur lors de la construction du chemin'); dispatch({ type: 'E_PATH_INVALID' }); } finally { setIsBuilding(false); } }, [serviceUuid, membreUuid, graphResolver, handleError, clearError, dispatch]); // Construire le chemin automatiquement si service et membre sont fournis useEffect(() => { if ( serviceUuid !== '' && membreUuid !== '' && loginPath === null && loginState === 'S_LOGIN_BUILD_PATH' && identity !== null ) { void handleBuildPath(); } }, [serviceUuid, membreUuid, loginPath, loginState, identity, handleBuildPath]); const handleBuildChallenge = async (): Promise => { if (identity === null || loginPath === null) { handleError('Identité ou chemin de login manquant', 'MISSING_REQUIREMENTS'); return; } if (loginPath.statut !== 'complet') { handleError('Le chemin de login doit être complet avant de construire le challenge', 'INCOMPLETE_PATH'); return; } setIsBuilding(true); clearError(); try { const relays = getStoredRelays().map((r) => r.endpoint); if (relays.length === 0) { handleError('Aucun relais configuré', 'NO_RELAYS'); return; } const loginBuilder = new LoginBuilder(identity, relays); const challenge = await loginBuilder.buildChallenge( loginPath.service_uuid, loginPath.action_login_uuid, loginPath.action_login_uuid, loginPath.membre_uuid, ); const localPairs = getPairsForMember(loginPath.membre_uuid).filter( (p) => p.is_local && loginPath.pairs_attendus.includes(p.uuid), ); const pk = identity.privateKey; if (pk === undefined) { handleError('Clé privée indisponible. Déverrouillez l\'identité.', 'IDENTITY_LOCKED'); return; } const signatures: Array<{ signature: string; cle_publique: string; nonce: string; pair_uuid: string; }> = []; for (const pair of localPairs) { const sig = loginBuilder.signChallenge(challenge, pair.uuid, pk); signatures.push({ signature: sig, cle_publique: identity.publicKey, nonce: challenge.nonce, pair_uuid: pair.uuid, }); } if (signatures.length === 0) { handleError( 'Aucun pair local pour ce membre. Signez sur ce device ou ajoutez un pair.', 'NO_LOCAL_PAIRS', ); return; } const newProof = await loginBuilder.buildProof(challenge, signatures); setProof(newProof); dispatch({ type: 'E_CHALLENGE_READY' }); dispatch({ type: 'E_SIGNATURES_COMPLETE' }); } catch (err) { handleError(err, 'Erreur lors de la construction du challenge'); } finally { setIsBuilding(false); } }; /** * Publish login proof to relays. * Order: message → signatures → keys (strict order as per specs). * Then collect remote signatures if needed (mFA with 2 devices). * Finally verify locally and send proof to parent if in iframe. */ const handlePublish = async (): Promise => { if (identity === null || proof === null) { handleError('Identité ou preuve manquante', 'MISSING_REQUIREMENTS'); return; } // Anti-rejeu: vérifier timestamp dans la fenêtre if (!verifyTimestamp(proof.challenge.timestamp)) { handleError( 'Timestamp hors fenêtre de validité (anti-rejeu)', 'X_TIMESTAMP_OUT_OF_WINDOW', ); return; } // Anti-rejeu: vérifier nonce unique await nonceStore.init(); if (nonceStore.hasUsed(proof.challenge.nonce)) { handleError('Nonce déjà utilisé (anti-rejeu)', 'X_NONCE_REUSED'); return; } setIsPublishing(true); clearError(); try { const relays = getStoredRelays().filter((r) => r.enabled); if (relays.length === 0) { handleError('Aucun relais activé', 'NO_ENABLED_RELAYS'); return; } const loginBuilder = new LoginBuilder(identity, relays.map((r) => r.endpoint)); const msgChiffre = loginBuilder.challengeToMsgChiffre(proof.challenge); const msgCle = loginBuilder.challengeToMsgCle(proof.challenge); // Publication en ordre strict : message → signatures → clés const publishResult = await publishMessageAndSigs( relays, msgChiffre, proof.signatures, msgCle, ); setPublishRelayStatus(publishResult.relayStatus); if (publishResult.successCount === 0) { handleError('Échec de la publication sur tous les relais', 'PUBLISH_FAILED'); return; } // Collecte des signatures distantes (mFA avec 2 devices) let merged = proof.signatures; if (loginPath !== null) { setIsCollecting(true); setCollectProgressState(null); try { const pairToMembers = buildPairToMembers(loginPath.pairs_attendus); const pubkeyToPair = buildPubkeyToPair( identity.publicKey, loginPath.pairs_attendus, ); const endpoints = relays.map((r) => r.endpoint); // Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout merged = await runCollectLoop({ relayEndpoints: endpoints, hash: proof.challenge.hash, ourSigs: proof.signatures, path: loginPath, pairToMembers, pubkeyToPair, opts: { pollMs: COLLECT_POLL_MS, timeoutMs: COLLECT_TIMEOUT_MS, onProgress: (m) => { const p = collectProgress(loginPath, m, pairToMembers); setCollectProgressState({ satisfied: p.satisfied, required: p.required, }); }, }, }); } finally { setIsCollecting(false); setCollectProgressState(null); } } // Si des signatures distantes ont été collectées, demander confirmation utilisateur if ( loginPath !== null && hasRemoteSignatures(merged, loginPath.pairs_attendus) ) { setCollectedMerged(merged); setCollectedPublishStats({ successCount: publishResult.successCount, relaysCount: relays.length, relayStatus: publishResult.relayStatus, }); setAwaitingRemoteAccept(true); setIsPublishing(false); return; } const finalProof = await loginBuilder.buildProof(proof.challenge, merged); // Vérification locale finale (avant envoi au parent) // Vérifie: hash, signatures (clés autorisées), dépendances, graphe, anti-rejeu const verificationResults: string[] = []; const verificationErrors: string[] = []; let verificationSuccess = true; // Vérifier hash verificationResults.push(`✓ Hash: ${proof.challenge.hash.slice(0, 16)}...`); // Vérifier signatures if (loginPath !== null) { const allowedPubkeys = buildAllowedPubkeys(loginPath, identity.publicKey); if (allowedPubkeys.size > 0) { const minimalMsg = { hash: { hash_value: proof.challenge.hash }, } as unknown as MessageBase; const sigs = merged.map((s) => ({ hash: proof.challenge.hash, cle_publique: s.cle_publique, signature: s.signature, nonce: s.nonce, })); const { valid, unauthorized } = verifyMessageSignaturesStrict( minimalMsg, sigs, allowedPubkeys, ); if (valid.length > 0) { verificationResults.push(`✓ Signatures valides: ${valid.length}`); } else { verificationErrors.push('Aucune signature valide'); verificationSuccess = false; } if (unauthorized.length > 0) { verificationErrors.push( `Signatures non autorisées: ${unauthorized.length} (clés: ${unauthorized.map((s) => s.cle_publique.slice(0, 16)).join(', ')}...)`, ); verificationSuccess = false; } } else { verificationErrors.push('Aucune clé publique autorisée trouvée dans les validateurs'); verificationSuccess = false; } // Vérifier dépendances if (!checkDependenciesSatisfied(loginPath, finalProof)) { verificationErrors.push('Dépendances entre signatures non satisfaites (membres requis manquants)'); verificationSuccess = false; } // Vérifier graphe if (loginPath.statut === 'complet') { verificationResults.push('✓ Graphe complet'); } else { verificationErrors.push('Graphe incomplet - objets manquants dans le cache'); verificationSuccess = false; } } // Vérifier anti-rejeu verificationResults.push('✓ Nonce unique'); verificationResults.push('✓ Timestamp dans la fenêtre'); // Si vérification échoue, arrêter ici if (!verificationSuccess) { // Log structuré pour le débogage const verificationLog = { timestamp: new Date().toISOString(), hash: proof.challenge.hash, nonce: proof.challenge.nonce, serviceUuid: loginPath?.service_uuid, membreUuid: loginPath?.membre_uuid, results: verificationResults, errors: verificationErrors, signaturesCount: merged.length, loginPathStatus: loginPath?.statut, }; console.error('[Login] Verification failed:', verificationLog); handleError( `Vérification locale échouée: ${verificationErrors.join('; ')}`, 'VERIFICATION_FAILED', ); dispatch({ type: 'E_LOCAL_VERDICT_REJECT' }); return; } // Log structuré pour le succès console.warn('[Login] Verification succeeded:', { timestamp: new Date().toISOString(), hash: proof.challenge.hash.slice(0, 16) + '...', serviceUuid: loginPath?.service_uuid, membreUuid: loginPath?.membre_uuid, signaturesCount: merged.length, }); await nonceStore.markUsed(proof.challenge.nonce, proof.challenge.timestamp); const updatedProof = { ...finalProof, statut: 'publie' as const }; setProof(updatedProof); sendLoginProof(updatedProof); if (publishResult.successCount < relays.length) { handleError( `Publication partielle: ${publishResult.successCount}/${relays.length} relais`, 'PARTIAL_PUBLISH', ); dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' }); } else { dispatch({ type: 'E_PUBLISH_LOGIN_OK' }); dispatch({ type: 'E_LOCAL_VERDICT_ACCEPT' }); } } catch (err) { handleError(err, 'Erreur lors de la publication'); } finally { setIsPublishing(false); } }; if (identity === null) { return (

Identité requise pour se connecter

); } if (!isPairingSatisfied()) { return (

Se connecter

Pairing obligatoire (G_PAIRING_SATISFIED). Configurez au moins un pair.

); } const handleBack = (): void => { dispatch({ type: 'E_BACK' }); navigate('/'); }; const handleSyncNow = (): void => { dispatch({ type: 'E_SYNC_NOW' }); navigate('/sync'); }; const handleAddPair = (): void => { dispatch({ type: 'E_ADD_PAIR' }); navigate('/manage-pairs'); }; const handleRefuseRemote = (): void => { dispatch({ type: 'E_LOCAL_VERDICT_REJECT' }); setAwaitingRemoteAccept(false); setCollectedMerged(null); setCollectedPublishStats(null); }; const handleAcceptRemote = async (): Promise => { if ( identity === null || proof === null || loginPath === null || collectedMerged === null || collectedPublishStats === null ) { return; } clearError(); const relays = getStoredRelays().filter((r) => r.enabled); const loginBuilder = new LoginBuilder( identity, relays.map((r) => r.endpoint), ); const finalProof = await loginBuilder.buildProof( proof.challenge, collectedMerged, ); if (!checkDependenciesSatisfied(loginPath, finalProof)) { handleError( 'Dépendances entre signatures non satisfaites (membres requis manquants)', 'DEPENDENCIES_UNSATISFIED', ); return; } const allowedPubkeys = buildAllowedPubkeys(loginPath, identity.publicKey); if (allowedPubkeys.size > 0) { const minimalMsg = { hash: { hash_value: proof.challenge.hash }, } as unknown as MessageBase; const sigs = collectedMerged.map((s) => ({ hash: proof.challenge.hash, cle_publique: s.cle_publique, signature: s.signature, nonce: s.nonce, })); const { valid, unauthorized } = verifyMessageSignaturesStrict( minimalMsg, sigs, allowedPubkeys, ); if (unauthorized.length > 0 || valid.length === 0) { handleError( 'Signature(s) avec clé non autorisée par les validateurs (X_PUBKEY_NOT_AUTHORIZED)', 'X_PUBKEY_NOT_AUTHORIZED', ); return; } } await nonceStore.markUsed( proof.challenge.nonce, proof.challenge.timestamp, ); const updatedProof = { ...finalProof, statut: 'publie' as const }; setProof(updatedProof); sendLoginProof(updatedProof); if ( collectedPublishStats.successCount < collectedPublishStats.relaysCount ) { handleError( `Publication partielle: ${collectedPublishStats.successCount}/${collectedPublishStats.relaysCount} relais`, 'PARTIAL_PUBLISH', ); dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' }); } else { dispatch({ type: 'E_PUBLISH_LOGIN_OK' }); dispatch({ type: 'E_LOCAL_VERDICT_ACCEPT' }); } setAwaitingRemoteAccept(false); setCollectedMerged(null); setCollectedPublishStats(null); }; const showRecoveryActions = (loginPath !== null && loginPath.statut === 'incomplet') || loginState === 'S_ERROR_RECOVERABLE'; const handleRetry = (): void => { dispatch({ type: 'E_RETRY' }); if (serviceUuid !== '' && membreUuid !== '') { void handleBuildPath(); } }; return (

Se connecter

État: {loginState}

{error !== null && } {(loginState === 'S_LOGIN_SELECT_SERVICE' || loginState === 'S_LOGIN_SELECT_MEMBER') && (

Redirection vers la sélection...

)} {loginPath !== null && (

Chemin de login

Statut: {loginPath.statut}

{loginPath.contrat_version !== undefined && (

Version contrat: {loginPath.contrat_version}

)}

Résumé du chemin

  • Service: {loginPath.service_uuid}
  • {loginPath.contrat_uuid.length > 0 && (
  • Contrat(s):{' '} {loginPath.contrat_uuid.join(', ')}
  • )} {loginPath.champ_uuid !== undefined && loginPath.champ_uuid.length > 0 && (
  • Champ(s):{' '} {loginPath.champ_uuid.join(', ')}
  • )}
  • Action login: {loginPath.action_login_uuid}
  • Membre: {loginPath.membre_uuid}
  • Pairs attendus: {loginPath.pairs_attendus.length}
{loginPath.signatures_requises.length > 0 && (

Signatures requises

{loginPath.signatures_requises.map((req, idx) => { // Déterminer le statut de cette signature const proofSigs = proof?.signatures ?? []; const collectedSigs = collectedMerged ?? []; const allSigs = [...proofSigs, ...collectedSigs]; const hasSignature = req.cle_publique !== undefined && allSigs.some((s) => s.cle_publique === req.cle_publique); const status = hasSignature ? 'reçue' : 'manquante'; return ( ); })}
Membre UUID Pair UUID Clé publique Cardinalité min. Dépendances Statut
{req.membre_uuid.slice(0, 8)}... {req.pair_uuid !== undefined ? `${req.pair_uuid.slice(0, 8)}...` : '—'} {req.cle_publique !== undefined ? `${req.cle_publique.slice(0, 16)}...` : '—'} {req.cardinalite_minimale ?? '1'} {req.dependances !== undefined && req.dependances.length > 0 ? req.dependances.join(', ') : '—'} {status === 'reçue' ? '✓ Reçue' : '✗ Manquante'}
)} {loginPath.statut === 'complet' && ( )} {loginPath.statut === 'incomplet' && showRecoveryActions && (
)}
)} {loginState === 'S_ERROR_RECOVERABLE' && (

Erreur récupérable

Une erreur s'est produite, mais elle peut être corrigée. Choisissez une action :

)} {loginState === 'S_ERROR_FATAL' && (

Erreur fatale

Une erreur bloquante s'est produite. Impossible de continuer sans intervention externe.

{error !== null && (

Erreur: {error.message}

{error.code !== undefined && (

Code: {error.code}

)}
)}
)} {showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (
)} {proof !== null && (

Message de login à valider

Résumé public

  • Service: {loginPath?.service_uuid ?? 'N/A'}
  • Type: {proof.challenge.datajson_public.types_uuid.join(', ')}
  • Timestamp:{' '} {new Date(proof.challenge.datajson_public.timestamp).toLocaleString()}
  • Relais: {proof.challenge.datajson_public.services_uuid.length}

Détails techniques (mode avancé)

Hash: {proof.challenge.hash}

Nonce: {proof.challenge.nonce}

Statut: {proof.statut}

Signatures: {proof.signatures.length}

{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
{publishRelayStatus.length > 0 && (

Statut par relais

{publishRelayStatus.map((status, idx) => ( ))}
Relais Statut Erreur
{status.endpoint} {status.ok ? '✓ OK' : '✗ Échec'} {status.error ?? '—'}
)}
)} {isCollecting && proof !== null && ( { void (async (): Promise => { if (loginPath === null || proof === null) { return; } setIsCollecting(true); try { const relays = getStoredRelays().filter((r) => r.enabled); const endpoints = relays.map((r) => r.endpoint); const pairToMembers = buildPairToMembers(loginPath.pairs_attendus); const pubkeyToPair = buildPubkeyToPair( identity?.publicKey ?? '', loginPath.pairs_attendus, ); const merged = await runCollectLoop({ relayEndpoints: endpoints, hash: proof.challenge.hash, ourSigs: proof.signatures, path: loginPath, pairToMembers, pubkeyToPair, opts: { pollMs: COLLECT_POLL_MS, timeoutMs: COLLECT_TIMEOUT_MS, onProgress: (m) => { const p = collectProgress(loginPath, m, pairToMembers); setCollectProgressState({ satisfied: p.satisfied, required: p.required, }); }, }, }); setCollectedMerged(merged); } finally { setIsCollecting(false); } })(); }} onViewDetails={(requirement, pairUuid) => { // Afficher les détails de la signature dans une alerte ou un modal const detail = `Membre: ${requirement}\nPair: ${pairUuid ?? 'N/A'}\nHash: ${proof.challenge.hash.slice(0, 16)}...\nNonce: ${proof.challenge.nonce.slice(0, 16)}...`; alert(detail); }} /> )} {awaitingRemoteAccept && collectedMerged !== null && proof !== null && loginPath !== null && (

Confirmer les signatures du 2ᵉ appareil

Des signatures ont été reçues du 2ᵉ appareil. Les mots ont pu être visibles à l'écran et interceptés par une tierce personne. Confirmer que c'est bien vous qui avez validé sur l'autre appareil ?

)}
)} {loginState === 'S_LOGIN_SUCCESS' && (

Login réussi

La preuve de login a été acceptée et la session est ouverte.

)} {loginState === 'S_LOGIN_FAILURE' && (

Login échoué

La vérification locale a échoué. Diagnostics :

{error !== null && (

Erreur: {error.message}

{error.code !== undefined && (

Code: {error.code}

)}
)}

Actions possibles :

  • Signatures manquantes : Vérifiez que tous les pairs requis ont signé. Utilisez "Rafraîchir" dans la collecte de signatures.
  • Objets manquants : Synchronisez les données pour récupérer les contrats, membres, pairs manquants.
  • Anti-rejeu : Le nonce a peut-être été réutilisé. Un nouveau login générera un nouveau nonce.
  • Clés non autorisées : Vérifiez que les clés publiques des signatures correspondent aux validateurs du contrat.
)}
); }