**Motivations:** - Partie connectée du skeleton accessible seulement si pairing satisfait + relais OK, avec page type skeleton (avatar, notifications). - Éviter « Aucun service disponible » : contrat présent en dur dans la page, transmis à l’iframe ; navigation évidente ou automatique vers login. - Sécuriser postMessage (origine UserWallet uniquement) ; déployer data sur le proxy et certificat data.certificator.4nkweb.com. - Vulgariser cryptographie (ECDH, AES-GCM, Schnorr, workflow, collecte signatures) ; documenter correctifs et architecture. **Root causes:** - Section connectée affichée sans vérifier pairing/relay ; possibilité de forger pairing-relay-status depuis la console. - Iframe masquée ou /login chargé avant réception du contrat → graphe vide, redirection vers /services. - Pas de contrôle d’origine sur les messages reçus ; pas de projet website-data ni config Nginx/certificat pour data. **Correctifs:** - Vérification msg.origin === USERWALLET_ORIGIN dans handleMessage (skeleton). - Si session mais pas pairingRelayStatus : afficher iframe pour réception du statut, message « Vérification du statut… ». - Contrat envoyé dès load iframe (init iframe.src = USERWALLET_ORIGIN) ; au clic « Se connecter », envoi contract + navigate-login (service, membre). - UserWallet : écoute navigate-login → navigation /login?service=&membre= ; LoginScreen avec service+membre en URL ne redirige plus vers /services, dispatch E_SELECT_SERVICE / E_SELECT_MEMBER. **Evolutions:** - Message pairing-relay-status (iframe → parent) ; canShowConnectedSection exige login + pairing OK + relay OK ; page connectée avec header avatar + icône notifications. - Skeleton : getLoginContext, sendNavigateLoginToIframe, onIframeLoad, loginRequested/iframeLoaded ; contrat envoyé avec serviceUuid, membreUuid. - UserWallet : PairingRelayStatusMessage, envoi depuis HomeScreen/LoginScreen ; type navigate-login, handleNavigateLogin dans useChannel. - Page cryptographie.html (workflow, algorithmes, collecte signatures) ; liens nav, build. - website-data (Vite, channel, config), start/service/install ; configure-nginx-proxy + Certbot pour data.certificator.4nkweb.com. - fixKnowledge (postmessage-origin, section-connectee-non-affichee) ; features (partie-connectee-pairing-relay, userwallet-iframe-key-isolation). **Pages affectées:** - website-skeleton (index, main, config, serviceContract, cryptographie, technique, membre, contrat, vite.config, README). - userwallet (HomeScreen, LoginScreen, useChannel, iframeChannel, relay, crypto, iframe, Pairing*, RelaySettings, WordInputGrid, syncUpdateGraph, specs/synthese). - website-data (nouveau), configure-nginx-proxy, docs DOMAINS_AND_PORTS README, features, fixKnowledge, userwallet features/docs.
1079 lines
37 KiB
TypeScript
1079 lines
37 KiB
TypeScript
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<LoginPath | null>(null);
|
|
const [proof, setProof] = useState<LoginProof | null>(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<ProofSignature[] | null>(
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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 (
|
|
<main>
|
|
<p>Identité requise pour se connecter</p>
|
|
<button type="button" onClick={(): void => navigate('/')}>
|
|
Retour
|
|
</button>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!isPairingSatisfied()) {
|
|
return (
|
|
<main>
|
|
<h1>Se connecter</h1>
|
|
<p>Pairing obligatoire (G_PAIRING_SATISFIED). Configurez au moins un pair.</p>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => navigate('/manage-pairs')}
|
|
>
|
|
Configurer le pairing
|
|
</button>
|
|
<button type="button" onClick={(): void => navigate('/')}>
|
|
Retour
|
|
</button>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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<void> => {
|
|
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 (
|
|
<main>
|
|
<h1>Se connecter</h1>
|
|
<p role="status" aria-live="polite" className="sr-only">
|
|
État: {loginState}
|
|
</p>
|
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
|
{(loginState === 'S_LOGIN_SELECT_SERVICE' ||
|
|
loginState === 'S_LOGIN_SELECT_MEMBER') && (
|
|
<section aria-labelledby="redirect-info">
|
|
<p>Redirection vers la sélection...</p>
|
|
</section>
|
|
)}
|
|
{loginPath !== null && (
|
|
<section aria-labelledby="login-path">
|
|
<h2 id="login-path">Chemin de login</h2>
|
|
<div>
|
|
<p>
|
|
<strong>Statut:</strong> {loginPath.statut}
|
|
</p>
|
|
{loginPath.contrat_version !== undefined && (
|
|
<p>
|
|
<strong>Version contrat:</strong> {loginPath.contrat_version}
|
|
</p>
|
|
)}
|
|
<div>
|
|
<h3>Résumé du chemin</h3>
|
|
<ul>
|
|
<li>
|
|
<strong>Service:</strong> {loginPath.service_uuid}
|
|
</li>
|
|
{loginPath.contrat_uuid.length > 0 && (
|
|
<li>
|
|
<strong>Contrat(s):</strong>{' '}
|
|
{loginPath.contrat_uuid.join(', ')}
|
|
</li>
|
|
)}
|
|
{loginPath.champ_uuid !== undefined &&
|
|
loginPath.champ_uuid.length > 0 && (
|
|
<li>
|
|
<strong>Champ(s):</strong>{' '}
|
|
{loginPath.champ_uuid.join(', ')}
|
|
</li>
|
|
)}
|
|
<li>
|
|
<strong>Action login:</strong> {loginPath.action_login_uuid}
|
|
</li>
|
|
<li>
|
|
<strong>Membre:</strong> {loginPath.membre_uuid}
|
|
</li>
|
|
<li>
|
|
<strong>Pairs attendus:</strong> {loginPath.pairs_attendus.length}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
{loginPath.signatures_requises.length > 0 && (
|
|
<div>
|
|
<h3>Signatures requises</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Membre UUID</th>
|
|
<th>Pair UUID</th>
|
|
<th>Clé publique</th>
|
|
<th>Cardinalité min.</th>
|
|
<th>Dépendances</th>
|
|
<th>Statut</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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 (
|
|
<tr key={idx}>
|
|
<td>{req.membre_uuid.slice(0, 8)}...</td>
|
|
<td>
|
|
{req.pair_uuid !== undefined
|
|
? `${req.pair_uuid.slice(0, 8)}...`
|
|
: '—'}
|
|
</td>
|
|
<td>
|
|
{req.cle_publique !== undefined
|
|
? `${req.cle_publique.slice(0, 16)}...`
|
|
: '—'}
|
|
</td>
|
|
<td>
|
|
{req.cardinalite_minimale ?? '1'}
|
|
</td>
|
|
<td>
|
|
{req.dependances !== undefined &&
|
|
req.dependances.length > 0
|
|
? req.dependances.join(', ')
|
|
: '—'}
|
|
</td>
|
|
<td>
|
|
{status === 'reçue' ? '✓ Reçue' : '✗ Manquante'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{loginPath.statut === 'complet' && (
|
|
<button
|
|
type="button"
|
|
onClick={handleBuildChallenge}
|
|
disabled={isBuilding}
|
|
>
|
|
{isBuilding ? 'Construction...' : 'Démarrer le login'}
|
|
</button>
|
|
)}
|
|
{loginPath.statut === 'incomplet' && showRecoveryActions && (
|
|
<div>
|
|
<button type="button" onClick={handleSyncNow}>
|
|
Synchroniser
|
|
</button>
|
|
<button type="button" onClick={handleAddPair}>
|
|
Ajouter un pair
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
{loginState === 'S_ERROR_RECOVERABLE' && (
|
|
<section aria-labelledby="error-recoverable">
|
|
<h2 id="error-recoverable">Erreur récupérable</h2>
|
|
<p>
|
|
Une erreur s'est produite, mais elle peut être corrigée. Choisissez
|
|
une action :
|
|
</p>
|
|
<div>
|
|
<button type="button" onClick={handleRetry}>
|
|
Réessayer
|
|
</button>
|
|
<button type="button" onClick={handleSyncNow}>
|
|
Synchroniser maintenant
|
|
</button>
|
|
<button type="button" onClick={handleAddPair}>
|
|
Ajouter un pair
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_OPEN_DIAGNOSTIC' });
|
|
navigate('/diagnostic');
|
|
}}
|
|
>
|
|
Diagnostic avancé
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_BACK' });
|
|
navigate('/');
|
|
}}
|
|
>
|
|
Retour à l'accueil
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{loginState === 'S_ERROR_FATAL' && (
|
|
<section aria-labelledby="error-fatal">
|
|
<h2 id="error-fatal">Erreur fatale</h2>
|
|
<p>
|
|
Une erreur bloquante s'est produite. Impossible de continuer sans
|
|
intervention externe.
|
|
</p>
|
|
{error !== null && (
|
|
<div>
|
|
<p>
|
|
<strong>Erreur:</strong> {error.message}
|
|
</p>
|
|
{error.code !== undefined && (
|
|
<p>
|
|
<strong>Code:</strong> {error.code}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_EXIT' });
|
|
navigate('/');
|
|
}}
|
|
>
|
|
Quitter
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (
|
|
<section aria-label="Reprise">
|
|
<button type="button" onClick={handleSyncNow}>
|
|
Synchroniser
|
|
</button>
|
|
<button type="button" onClick={handleAddPair}>
|
|
Ajouter un pair
|
|
</button>
|
|
</section>
|
|
)}
|
|
{proof !== null && (
|
|
<section aria-labelledby="login-proof">
|
|
<h2 id="login-proof">Message de login à valider</h2>
|
|
<div>
|
|
<div>
|
|
<h3>Résumé public</h3>
|
|
<ul>
|
|
<li>
|
|
<strong>Service:</strong> {loginPath?.service_uuid ?? 'N/A'}
|
|
</li>
|
|
<li>
|
|
<strong>Type:</strong> {proof.challenge.datajson_public.types_uuid.join(', ')}
|
|
</li>
|
|
<li>
|
|
<strong>Timestamp:</strong>{' '}
|
|
{new Date(proof.challenge.datajson_public.timestamp).toLocaleString()}
|
|
</li>
|
|
<li>
|
|
<strong>Relais:</strong> {proof.challenge.datajson_public.services_uuid.length}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h3>Détails techniques (mode avancé)</h3>
|
|
<p>
|
|
<strong>Hash:</strong> {proof.challenge.hash}
|
|
</p>
|
|
<p>
|
|
<strong>Nonce:</strong> {proof.challenge.nonce}
|
|
</p>
|
|
<p>
|
|
<strong>Statut:</strong> {proof.statut}
|
|
</p>
|
|
<p>
|
|
<strong>Signatures:</strong> {proof.signatures.length}
|
|
</p>
|
|
</div>
|
|
{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void handlePublish();
|
|
}}
|
|
disabled={isPublishing}
|
|
>
|
|
{isCollecting
|
|
? 'En attente des signatures des autres appareils…'
|
|
: isPublishing
|
|
? 'Publication…'
|
|
: 'Publier la preuve'}
|
|
</button>
|
|
{publishRelayStatus.length > 0 && (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<h4>Statut par relais</h4>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Relais</th>
|
|
<th>Statut</th>
|
|
<th>Erreur</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{publishRelayStatus.map((status, idx) => (
|
|
<tr key={idx}>
|
|
<td>{status.endpoint}</td>
|
|
<td>{status.ok ? '✓ OK' : '✗ Échec'}</td>
|
|
<td>{status.error ?? '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isCollecting && proof !== null && (
|
|
<LoginCollectShare
|
|
proof={proof}
|
|
loginPath={loginPath}
|
|
collectProgress={collectProgressState}
|
|
collectedSignatures={collectedMerged ?? undefined}
|
|
onRefresh={() => {
|
|
void (async (): Promise<void> => {
|
|
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 && (
|
|
<section
|
|
aria-labelledby="confirm-remote-sigs"
|
|
style={{ marginTop: '1rem' }}
|
|
>
|
|
<h3 id="confirm-remote-sigs">
|
|
Confirmer les signatures du 2ᵉ appareil
|
|
</h3>
|
|
<p>
|
|
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 ?
|
|
</p>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => void handleAcceptRemote()}
|
|
>
|
|
Accepter
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleRefuseRemote}
|
|
>
|
|
Refuser
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
{loginState === 'S_LOGIN_SUCCESS' && (
|
|
<section aria-labelledby="login-success">
|
|
<h2 id="login-success">Login réussi</h2>
|
|
<p>La preuve de login a été acceptée et la session est ouverte.</p>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_DONE' });
|
|
navigate('/');
|
|
}}
|
|
>
|
|
Terminer
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{loginState === 'S_LOGIN_FAILURE' && (
|
|
<section aria-labelledby="login-failure">
|
|
<h2 id="login-failure">Login échoué</h2>
|
|
<p>La vérification locale a échoué. Diagnostics :</p>
|
|
{error !== null && (
|
|
<div>
|
|
<p>
|
|
<strong>Erreur:</strong> {error.message}
|
|
</p>
|
|
{error.code !== undefined && (
|
|
<p>
|
|
<strong>Code:</strong> {error.code}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h3>Actions possibles :</h3>
|
|
<ul>
|
|
<li>
|
|
<strong>Signatures manquantes :</strong> Vérifiez que tous les pairs
|
|
requis ont signé. Utilisez "Rafraîchir" dans la collecte de
|
|
signatures.
|
|
</li>
|
|
<li>
|
|
<strong>Objets manquants :</strong> Synchronisez les données pour
|
|
récupérer les contrats, membres, pairs manquants.
|
|
</li>
|
|
<li>
|
|
<strong>Anti-rejeu :</strong> Le nonce a peut-être été réutilisé. Un
|
|
nouveau login générera un nouveau nonce.
|
|
</li>
|
|
<li>
|
|
<strong>Clés non autorisées :</strong> Vérifiez que les clés publiques
|
|
des signatures correspondent aux validateurs du contrat.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_RETRY' });
|
|
if (serviceUuid !== '' && membreUuid !== '') {
|
|
void handleBuildPath();
|
|
}
|
|
}}
|
|
>
|
|
Réessayer
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_RESYNC' });
|
|
navigate('/sync');
|
|
}}
|
|
>
|
|
Resync
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(): void => {
|
|
dispatch({ type: 'E_BACK' });
|
|
navigate('/');
|
|
}}
|
|
>
|
|
Retour à l'accueil
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={handleBack}
|
|
disabled={awaitingRemoteAccept || loginState === 'S_LOGIN_SUCCESS' || loginState === 'S_LOGIN_FAILURE'}
|
|
>
|
|
Retour
|
|
</button>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|