diff --git a/RESTE_A_FAIRE.md b/RESTE_A_FAIRE.md
new file mode 100644
index 0000000..88f803a
--- /dev/null
+++ b/RESTE_A_FAIRE.md
@@ -0,0 +1,202 @@
+# Reste à faire - Projets UserWallet
+
+**Author:** Équipe 4NK
+**Date:** 2026-01-28
+
+## Vue d'ensemble
+
+Ce document liste les éléments restants à implémenter ou à valider pour les projets :
+- `userwallet`
+- `service-login-verify`
+- `api-relay`
+- `website-skeleton`
+
+---
+
+## 1. userwallet
+
+### 1.1 Écrans login (Priorité : Haute - À valider)
+
+**Statut :** À valider avant implémentation
+**Référence :** `features/userwallet-ecrans-login-a-valider.md`
+
+Les écrans suivants sont déjà en place mais nécessitent validation et éventuelles améliorations :
+- Sélection service / sélection membre
+- Construction du chemin login
+- Message de login à valider
+- Collecte signatures mFA
+- Publication
+- Vérification locale + résultat
+
+**Action requise :** Validation des écrans existants et ajustements selon les retours de tests.
+
+### 1.2 Notifications relais (Priorité : Haute)
+
+**Statut :** Partiellement implémenté
+**Référence :** `features/userwallet-contrat-login-reste-a-faire.md` (§ 3.2)
+
+**Fait :**
+- Progression collecte signatures (X/Y) implémentée via `onProgress` dans `runCollectLoop`
+- Affichage dans `LoginCollectShare`
+
+**Reste à faire :**
+- Réagir à d'autres événements relais si extension (ex. push)
+- Hash à fetch pour signatures, contrats, membres, pairs, actions, champs
+- Une fois le hash connu : récupération sur le relai des signatures, contrats, membres, pairs, actions, champs
+- Les notifications doivent piloter : quel hash fetch, puis fetch signatures/clés et mise à jour du graphe
+
+### 1.3 Merkle trees (Priorité : Basse - Optionnel)
+
+**Statut :** Non implémenté (optionnel)
+**Référence :** `features/userwallet-contrat-login-reste-a-faire.md` (§ 3.6)
+
+**Description :** Checkpointing / accélération de scan pour optimiser la synchronisation à grande échelle.
+
+**Impact :** Amélioration de performance pour les volumes importants de données.
+
+### 1.4 Écran "synchronisation continue par service" (Priorité : Moyenne)
+
+**Statut :** Non implémenté
+**Référence :** `userwallet/docs/specs.md` (§ Écran "synchronisation continue par service")
+
+**Fonctionnalités attendues :**
+- Liste services + toggle sync automatique
+- Fréquence (min/heure/jour)
+- Fenêtre de scan
+- Accélérateurs : Bloom filter (taille, faux positifs estimés), Merkle (segments/périodes)
+- Bouton "lancer maintenant"
+
+**Traitements locaux :**
+- GET messages depuis dernier checkpoint
+- Dédup hash
+- Fetch signatures et clés si nécessaire
+- Validation graphe et droits
+
+**États / erreurs :**
+- Volume trop important → recommander Bloom/Merkle
+- Relais instables → backoff
+
+### 1.5 Écran "paramètres crypto (avancé)" (Priorité : Basse)
+
+**Statut :** Non implémenté
+**Référence :** `userwallet/docs/specs.md` (§ Écran "paramètres crypto (avancé)")
+
+**Fonctionnalités attendues :**
+- Algorithme hash (ex : sha256)
+- Canonisation JSON (mode strict)
+- Paramètres ECDH (secp256k1)
+- Paramètres KDF/symétrique (si exposés)
+- Politique anti-rejeu : TTL nonce, fenêtre timestamp
+
+**États / erreurs :**
+- Incompatibilité avec services existants
+- Paramètres non supportés par version logicielle
+
+### 1.6 Validation stricte CNIL (Priorité : Moyenne)
+
+**Statut :** Non implémentée
+**Référence :** `features/userwallet-champs-obligatoires-cnil.md`
+
+**Description :** Validation stricte CNIL (présence des champs lorsque requis) non implémentée ; à prévoir selon politique métier.
+
+**Action requise :** Définir la politique métier et implémenter la validation.
+
+### 1.7 Corrections ESLint (Priorité : Moyenne)
+
+**Statut :** Environ 95 erreurs restantes
+**Référence :** `features/userwallet-eslint-fix.md`
+
+**Types d'erreurs :**
+- `max-lines` : fichiers trop longs
+- `max-lines-per-function` : fonctions trop longues
+- `complexity` : complexité cyclomatique élevée
+- `max-params` : trop de paramètres
+- `no-non-null-assertion` : utilisation de `!`
+- Et autres violations
+
+**Action requise :** Refactoring progressif pour corriger les violations.
+
+---
+
+## 2. service-login-verify
+
+### 2.1 Persistance du NonceCache (Priorité : Basse - Optionnel)
+
+**Statut :** Actuellement en mémoire uniquement
+**Référence :** `features/service-login-verify.md`
+
+**Description :** Le `NonceCache` actuel est en mémoire avec TTL configurable. Une persistance optionnelle (IndexedDB, Redis, etc.) peut être ajoutée si nécessaire.
+
+**Note :** L'interface `NonceCacheLike` permet déjà d'implémenter une version persistante. Le cache en mémoire est suffisant pour la plupart des cas d'usage.
+
+**Action requise :** Implémenter uniquement si besoin de persistance entre redémarrages du service.
+
+---
+
+## 3. api-relay
+
+### 3.1 Évolutions futures (Priorité : Basse - Optionnel)
+
+**Statut :** Non implémentées (évolutions futures possibles)
+**Référence :** `userwallet/features/api-relay.md`
+
+**Évolutions mentionnées :**
+- **Base de données** : Remplacer le stockage en mémoire par une base de données (SQLite, PostgreSQL)
+- **Authentification** : Ajouter une authentification pour les endpoints POST
+- **Rate limiting** : Limiter le nombre de requêtes par IP (déjà implémenté partiellement)
+- **Compression** : Compresser les messages stockés
+- **Indexation avancée** : Indexer par service UUID, type UUID pour des requêtes plus rapides
+- **WebSocket** : Support WebSocket pour les notifications en temps réel (optionnel, car le modèle est pull-only)
+- **Métriques** : Exposer des métriques (Prometheus, etc.) - déjà implémenté partiellement
+- **Logging structuré** : Utiliser un logger structuré (Winston, Pino, etc.) - déjà implémenté (Pino)
+
+**Note :** La plupart de ces évolutions sont optionnelles et dépendent des besoins de production.
+
+---
+
+## 4. website-skeleton
+
+### 4.1 Aucun élément manquant identifié
+
+**Statut :** Complet
+**Description :** Aucun élément spécifique mentionné comme manquant dans la documentation.
+
+---
+
+## Synthèse par priorité
+
+### Priorité Haute
+1. **userwallet** : Validation des écrans login (1.1)
+2. **userwallet** : Notifications relais - événements push (1.2)
+
+### Priorité Moyenne
+3. **userwallet** : Écran "synchronisation continue par service" (1.4)
+4. **userwallet** : Validation stricte CNIL (1.6)
+5. **userwallet** : Corrections ESLint (1.7)
+
+### Priorité Basse (Optionnel)
+6. **userwallet** : Merkle trees (1.3)
+7. **userwallet** : Écran "paramètres crypto (avancé)" (1.5)
+8. **service-login-verify** : Persistance du NonceCache (2.1)
+9. **api-relay** : Évolutions futures (3.1)
+
+---
+
+## Notes importantes
+
+- Les éléments marqués "À valider" nécessitent une validation explicite avant implémentation.
+- Les éléments optionnels peuvent être implémentés selon les besoins et contraintes.
+- Les corrections ESLint doivent être faites progressivement pour ne pas bloquer le développement.
+- La plupart des fonctionnalités de base sont implémentées et fonctionnelles.
+
+---
+
+## Références
+
+- `features/userwallet-contrat-login-reste-a-faire.md`
+- `features/userwallet-ecrans-login-a-valider.md`
+- `features/userwallet-champs-obligatoires-cnil.md`
+- `features/userwallet-eslint-fix.md`
+- `features/service-login-verify.md`
+- `userwallet/docs/specs.md`
+- `userwallet/features/api-relay.md`
diff --git a/api-relay/src/routes/keys.ts b/api-relay/src/routes/keys.ts
index a4cf4df..7975fb0 100644
--- a/api-relay/src/routes/keys.ts
+++ b/api-relay/src/routes/keys.ts
@@ -39,6 +39,10 @@ export function createKeysRouter(
router.get('/:hash', (req: Request, res: Response): void => {
try {
const hash = req.params.hash as string;
+ if (hash.length === 0) {
+ res.status(400).json({ error: 'Hash parameter required' });
+ return;
+ }
const stored = storage.getKeys(hash);
const keys: MsgCle[] = stored.map((k) => k.msg);
res.json(keys);
diff --git a/api-relay/src/routes/messages.ts b/api-relay/src/routes/messages.ts
index af1c72f..876ce15 100644
--- a/api-relay/src/routes/messages.ts
+++ b/api-relay/src/routes/messages.ts
@@ -73,6 +73,10 @@ export function createMessagesRouter(
router.get('/:hash', (req: Request, res: Response): void => {
try {
const hash = req.params.hash as string;
+ if (hash.length === 0) {
+ res.status(400).json({ error: 'Hash parameter required' });
+ return;
+ }
const stored = storage.getMessage(hash);
if (stored === undefined) {
res.status(404).json({ error: 'Message not found' });
diff --git a/api-relay/src/routes/signatures.ts b/api-relay/src/routes/signatures.ts
index 194389f..46cb32c 100644
--- a/api-relay/src/routes/signatures.ts
+++ b/api-relay/src/routes/signatures.ts
@@ -17,6 +17,10 @@ export function createSignaturesRouter(
router.get('/:hash', (req: Request, res: Response): void => {
try {
const hash = req.params.hash as string;
+ if (hash.length === 0) {
+ res.status(400).json({ error: 'Hash parameter required' });
+ return;
+ }
const stored = storage.getSignatures(hash);
const signatures: MsgSignature[] = stored.map((s) => s.msg);
res.json(signatures);
diff --git a/service-login-verify/src/types.ts b/service-login-verify/src/types.ts
index f40b765..e1193e6 100644
--- a/service-login-verify/src/types.ts
+++ b/service-login-verify/src/types.ts
@@ -57,5 +57,5 @@ export interface NonceCacheLike {
export interface VerifyLoginProofResult {
accept: boolean;
- reason?: 'timestamp_out_of_window' | 'nonce_reused' | 'validators_not_verifiable' | 'no_validator_signature' | 'signature_cle_publique_not_authorized';
+ reason?: 'invalid_proof_structure' | 'timestamp_out_of_window' | 'nonce_reused' | 'validators_not_verifiable' | 'no_validator_signature' | 'signature_cle_publique_not_authorized';
}
diff --git a/service-login-verify/src/verifyLoginProof.ts b/service-login-verify/src/verifyLoginProof.ts
index d7ec7a7..8934d92 100644
--- a/service-login-verify/src/verifyLoginProof.ts
+++ b/service-login-verify/src/verifyLoginProof.ts
@@ -47,11 +47,61 @@ function verifySignaturesStrict(
/**
* Verify login proof: crypto, allowed pubkeys, timestamp window, nonce anti-replay.
* Service must provide allowedPubkeys (from validators) and a NonceCache.
+ *
+ * @param proof - Login proof from UserWallet
+ * @param ctx - Verification context (allowedPubkeys, nonceCache, timestampWindowMs)
+ * @returns Verification result with accept flag and optional reason
+ * @throws {Error} If proof structure is invalid (missing challenge, hash, signatures)
*/
export function verifyLoginProof(
proof: LoginProof,
ctx: VerifyLoginProofContext,
): VerifyLoginProofResult {
+ // Validate proof structure
+ if (proof.challenge === undefined || typeof proof.challenge !== 'object') {
+ return {
+ accept: false,
+ reason: 'invalid_proof_structure',
+ };
+ }
+
+ if (
+ typeof proof.challenge.hash !== 'string' ||
+ proof.challenge.hash.length === 0
+ ) {
+ return {
+ accept: false,
+ reason: 'invalid_proof_structure',
+ };
+ }
+
+ if (
+ typeof proof.challenge.nonce !== 'string' ||
+ proof.challenge.nonce.length === 0
+ ) {
+ return {
+ accept: false,
+ reason: 'invalid_proof_structure',
+ };
+ }
+
+ if (
+ typeof proof.challenge.timestamp !== 'number' ||
+ !Number.isFinite(proof.challenge.timestamp)
+ ) {
+ return {
+ accept: false,
+ reason: 'invalid_proof_structure',
+ };
+ }
+
+ if (!Array.isArray(proof.signatures)) {
+ return {
+ accept: false,
+ reason: 'invalid_proof_structure',
+ };
+ }
+
if (ctx.allowedPubkeys.size === 0) {
return {
accept: false,
diff --git a/userwallet/src/App.tsx b/userwallet/src/App.tsx
index e949edb..3fcf24d 100644
--- a/userwallet/src/App.tsx
+++ b/userwallet/src/App.tsx
@@ -17,6 +17,8 @@ import { LoginScreen } from './components/LoginScreen';
import { LoginSignScreen } from './components/LoginSignScreen';
import { ServiceListScreen } from './components/ServiceListScreen';
import { MemberSelectionScreen } from './components/MemberSelectionScreen';
+import { DiagnosticScreen } from './components/DiagnosticScreen';
+import { ServiceSyncScreen } from './components/ServiceSyncScreen';
import { DataExportImportScreen } from './components/DataExportImportScreen';
import { UnlockScreen } from './components/UnlockScreen';
import { useChannel } from './hooks/useChannel';
@@ -45,6 +47,8 @@ function AppContent(): JSX.Element {
} />
} />
} />
+ } />
+ } />
} />
);
diff --git a/userwallet/src/components/DiagnosticScreen.tsx b/userwallet/src/components/DiagnosticScreen.tsx
new file mode 100644
index 0000000..387b2b8
--- /dev/null
+++ b/userwallet/src/components/DiagnosticScreen.tsx
@@ -0,0 +1,332 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { GraphResolver } from '../services/graphResolver';
+import { useIdentity } from '../hooks/useIdentity';
+import { useErrorHandler } from '../hooks/useErrorHandler';
+import { ErrorDisplay } from './ErrorDisplay';
+import { getStoredRelays } from '../utils/relay';
+import {
+ getMessageByHash,
+ getSignatures,
+ getKeysInWindow,
+} from '../utils/relay';
+import {
+ verifyMessageHash,
+ verifyMessageSignatures,
+} from '../utils/verification';
+import {
+ getValidateursIfMessageAValider,
+ validateMessageAValiderForSync,
+} from '../utils/validatorsAccept';
+import { tryDecryptWithKeys } from '../services/syncDecrypt';
+import { updateGraphFromMessage } from '../services/syncUpdateGraph';
+import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
+import type { MessageBase } from '../types/message';
+
+interface DiagnosticResult {
+ hash: string;
+ messageFound: boolean;
+ messageDecrypted: boolean;
+ messageValidated: boolean;
+ hashValid: boolean;
+ signaturesFound: number;
+ signaturesValid: number;
+ keysFound: number;
+ validatorsChecked: boolean;
+ error?: string;
+ messageType?: string;
+ serviceUuid?: string[];
+ hashMismatch?: boolean;
+ signatureErrors?: string[];
+}
+
+export function DiagnosticScreen(): JSX.Element {
+ const navigate = useNavigate();
+ const { identity } = useIdentity();
+ const { error, handleError, clearError } = useErrorHandler();
+ const [hashInput, setHashInput] = useState('');
+ const [isDiagnosing, setIsDiagnosing] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const handleDiagnose = async (): Promise => {
+ if (hashInput.trim() === '') {
+ handleError('Hash requis', 'MISSING_HASH');
+ return;
+ }
+
+ setIsDiagnosing(true);
+ clearError();
+ setResult(null);
+
+ try {
+ const hash = hashInput.trim();
+ const relays = getStoredRelays().filter((r) => r.enabled);
+ if (relays.length === 0) {
+ handleError('Aucun relais activé', 'NO_ENABLED_RELAYS');
+ return;
+ }
+
+ const diagnostic: DiagnosticResult = {
+ hash,
+ messageFound: false,
+ messageDecrypted: false,
+ messageValidated: false,
+ hashValid: false,
+ signaturesFound: 0,
+ signaturesValid: 0,
+ keysFound: 0,
+ validatorsChecked: false,
+ };
+
+ // Fetch message by hash
+ let msgChiffre: MsgChiffre | null = null;
+ for (const relay of relays) {
+ try {
+ msgChiffre = await getMessageByHash(relay.endpoint, hash);
+ diagnostic.messageFound = true;
+ break;
+ } catch {
+ continue;
+ }
+ }
+
+ if (msgChiffre === null) {
+ diagnostic.error = 'Message non trouvé sur les relais';
+ setResult(diagnostic);
+ return;
+ }
+
+ // Fetch signatures
+ const allSignatures: MsgSignature[] = [];
+ for (const relay of relays) {
+ try {
+ const sigs = await getSignatures(relay.endpoint, hash);
+ allSignatures.push(...sigs);
+ } catch {
+ continue;
+ }
+ }
+ diagnostic.signaturesFound = allSignatures.length;
+
+ // Fetch keys
+ const allKeys: MsgCle[] = [];
+ const now = Date.now();
+ const start = now - 86400000; // 24h
+ for (const relay of relays) {
+ try {
+ const keys = await getKeysInWindow(relay.endpoint, start, now);
+ const relevantKeys = keys.filter((k) => k.hash_message === hash);
+ allKeys.push(...relevantKeys);
+ } catch {
+ continue;
+ }
+ }
+ diagnostic.keysFound = allKeys.length;
+
+ // Try to decrypt
+ if (identity !== null && allKeys.length > 0) {
+ const decrypted = await tryDecryptWithKeys(
+ msgChiffre,
+ allKeys,
+ identity,
+ );
+ if (decrypted !== null && typeof decrypted === 'object') {
+ const msg = decrypted as {
+ types?: { types_names_chiffres?: string };
+ datajson?: { services_uuid?: string[] };
+ hash?: { hash_value: string };
+ timestamp: number;
+ };
+ diagnostic.messageDecrypted = true;
+ diagnostic.messageType = msg.types?.types_names_chiffres ?? 'unknown';
+ diagnostic.serviceUuid = msg.datajson?.services_uuid;
+
+ const msgBase = decrypted as MessageBase;
+
+ // Verify hash
+ diagnostic.hashValid = await verifyMessageHash(msgBase);
+ if (!diagnostic.hashValid) {
+ diagnostic.hashMismatch = true;
+ diagnostic.error = 'Hash recalculé ≠ hash déclaré';
+ }
+
+ // Verify signatures
+ const signatureErrors: string[] = [];
+ if (allSignatures.length > 0) {
+ const sigList = allSignatures.map((s) => s.signature);
+ const { valid, invalid } = verifyMessageSignatures(msgBase, sigList);
+ diagnostic.signaturesValid = valid.length;
+
+ for (const inv of invalid) {
+ signatureErrors.push(
+ `Signature invalide (clé: ${inv.cle_publique.slice(0, 16)}...)`,
+ );
+ }
+
+ // Check validators if MessageAValider
+ const validateurs = getValidateursIfMessageAValider(msgBase);
+ if (validateurs !== null) {
+ diagnostic.validatorsChecked = true;
+ const isValid = validateMessageAValiderForSync(
+ msgBase,
+ sigList,
+ validateurs,
+ '[Diagnostic]',
+ );
+ diagnostic.messageValidated = isValid;
+ if (!isValid) {
+ signatureErrors.push('Signatures ne satisfont pas les validateurs');
+ }
+ } else {
+ diagnostic.messageValidated = valid.length > 0;
+ }
+ } else {
+ signatureErrors.push('Aucune signature trouvée');
+ }
+
+ if (signatureErrors.length > 0) {
+ diagnostic.signatureErrors = signatureErrors;
+ }
+
+ // Update graph if valid
+ if (diagnostic.hashValid && diagnostic.messageValidated) {
+ const graphResolver = new GraphResolver();
+ updateGraphFromMessage(decrypted, graphResolver);
+ }
+ }
+ }
+
+ setResult(diagnostic);
+ } catch (err) {
+ handleError(err, 'Erreur lors du diagnostic');
+ } finally {
+ setIsDiagnosing(false);
+ }
+ };
+
+ return (
+
+ Diagnostic hash / objet
+ {error !== null && }
+
+
+ {result !== null && (
+
+ Résultat du diagnostic
+
+
+ Hash: {result.hash}
+
+
+ Message trouvé: {result.messageFound ? '✓ Oui' : '✗ Non'}
+
+ {result.messageFound && (
+ <>
+
+ Message déchiffré:{' '}
+ {result.messageDecrypted ? '✓ Oui' : '✗ Non'}
+
+ {result.messageDecrypted && (
+ <>
+
+ Type: {result.messageType ?? 'N/A'}
+
+ {result.serviceUuid !== undefined && (
+
+ Service UUID: {result.serviceUuid.join(', ')}
+
+ )}
+
+ Hash valide:{' '}
+ {result.hashValid ? '✓ Oui' : '✗ Non'}
+
+ {result.hashMismatch === true && (
+
+ Erreur: Hash recalculé ≠ hash déclaré
+
+ )}
+
+ Validateurs vérifiés:{' '}
+ {result.validatorsChecked ? '✓ Oui' : '—'}
+
+
+ Message validé:{' '}
+ {result.messageValidated ? '✓ Oui' : '✗ Non'}
+
+ >
+ )}
+
+ Signatures trouvées: {result.signaturesFound}
+
+
+ Signatures valides: {result.signaturesValid}
+
+ {result.signatureErrors !== undefined &&
+ result.signatureErrors.length > 0 && (
+
+
Erreurs de signature:
+
+ {result.signatureErrors.map((err, idx) => (
+ -
+ {err}
+
+ ))}
+
+
+ )}
+
+ Clés trouvées: {result.keysFound}
+
+ {result.keysFound === 0 && result.messageFound && (
+
+ Attention: Aucune clé trouvée - message
+ indéchiffrable
+
+ )}
+ >
+ )}
+ {result.error !== undefined && (
+
+ Erreur: {result.error}
+
+ )}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/userwallet/src/components/LoginCollectShare.tsx b/userwallet/src/components/LoginCollectShare.tsx
index 94d8c63..1cf55a3 100644
--- a/userwallet/src/components/LoginCollectShare.tsx
+++ b/userwallet/src/components/LoginCollectShare.tsx
@@ -74,26 +74,27 @@ export function LoginCollectShare({
pairUuid: string | undefined;
clePublique: string | undefined;
status: 'manquante' | 'reçue' | 'valide' | 'invalide';
+ signature?: string;
}> => {
if (loginPath === null) {
return [];
}
- const collectedPubkeys = new Set(
- collectedSignatures?.map((s) => s.cle_publique) ?? [],
- );
- const localSignatures = new Set(
- proof.signatures.map((s) => s.cle_publique),
- );
+ const allSignatures = [
+ ...proof.signatures,
+ ...(collectedSignatures ?? []),
+ ];
return loginPath.signatures_requises.map((req) => {
- const hasCollected = req.cle_publique !== undefined &&
- collectedPubkeys.has(req.cle_publique);
- const hasLocal = req.cle_publique !== undefined &&
- localSignatures.has(req.cle_publique);
+ // Trouver la signature correspondante
+ const matchingSig = allSignatures.find(
+ (s) => s.cle_publique === req.cle_publique,
+ );
let status: 'manquante' | 'reçue' | 'valide' | 'invalide';
- if (hasLocal || hasCollected) {
+ if (matchingSig !== undefined) {
+ // Signature trouvée - considérée comme valide si elle correspond à la clé requise
+ // Note: la vérification crypto complète est faite ailleurs, ici on indique juste "reçue"
status = 'reçue';
} else {
status = 'manquante';
@@ -104,6 +105,7 @@ export function LoginCollectShare({
pairUuid: req.pair_uuid,
clePublique: req.cle_publique,
status,
+ signature: matchingSig?.signature,
};
});
};
diff --git a/userwallet/src/components/LoginScreen.tsx b/userwallet/src/components/LoginScreen.tsx
index c9ee332..a3e277f 100644
--- a/userwallet/src/components/LoginScreen.tsx
+++ b/userwallet/src/components/LoginScreen.tsx
@@ -54,7 +54,11 @@ export function LoginScreen(): JSX.Element {
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;
@@ -201,12 +205,19 @@ export function LoginScreen(): JSX.Element {
}
};
+ /**
+ * 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)',
@@ -215,6 +226,7 @@ export function LoginScreen(): JSX.Element {
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');
@@ -234,17 +246,20 @@ export function LoginScreen(): JSX.Element {
const msgChiffre = loginBuilder.challengeToMsgChiffre(proof.challenge);
const msgCle = loginBuilder.challengeToMsgCle(proof.challenge);
- const successCount = await publishMessageAndSigs(
+ // Publication en ordre strict : message → signatures → clés
+ const publishResult = await publishMessageAndSigs(
relays,
msgChiffre,
proof.signatures,
msgCle,
);
- if (successCount === 0) {
+ 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);
@@ -256,6 +271,7 @@ export function LoginScreen(): JSX.Element {
loginPath.pairs_attendus,
);
const endpoints = relays.map((r) => r.endpoint);
+ // Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout
merged = await runCollectLoop(
endpoints,
proof.challenge.hash,
@@ -281,14 +297,16 @@ export function LoginScreen(): JSX.Element {
}
}
+ // Si des signatures distantes ont été collectées, demander confirmation utilisateur
if (
loginPath !== null &&
hasRemoteSignatures(merged, loginPath.pairs_attendus)
) {
setCollectedMerged(merged);
setCollectedPublishStats({
- successCount,
+ successCount: publishResult.successCount,
relaysCount: relays.length,
+ relayStatus: publishResult.relayStatus,
});
setAwaitingRemoteAccept(true);
setIsPublishing(false);
@@ -297,7 +315,8 @@ export function LoginScreen(): JSX.Element {
const finalProof = await loginBuilder.buildProof(proof.challenge, merged);
- // Vérification locale finale
+ // 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;
@@ -361,10 +380,19 @@ export function LoginScreen(): JSX.Element {
// Si vérification échoue, arrêter ici
if (!verificationSuccess) {
- console.error('Login verification failed:', {
+ // 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',
@@ -373,15 +401,24 @@ export function LoginScreen(): JSX.Element {
return;
}
+ // Log structuré pour le succès
+ console.info('[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 (successCount < relays.length) {
+ if (publishResult.successCount < relays.length) {
handleError(
- `Publication partielle: ${successCount}/${relays.length} relais`,
+ `Publication partielle: ${publishResult.successCount}/${relays.length} relais`,
'PARTIAL_PUBLISH',
);
dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' });
@@ -601,35 +638,49 @@ export function LoginScreen(): JSX.Element {
Clé publique |
Cardinalité min. |
Dépendances |
+ Statut |
- {loginPath.signatures_requises.map((req, idx) => (
-
- | {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 !== undefined
- ? req.cardinalite_minimale
- : '1'}
- |
-
- {req.dependances !== undefined &&
- req.dependances.length > 0
- ? req.dependances.join(', ')
- : '—'}
- |
-
- ))}
+ {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 (
+
+ | {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 !== undefined
+ ? req.cardinalite_minimale
+ : '1'}
+ |
+
+ {req.dependances !== undefined &&
+ req.dependances.length > 0
+ ? req.dependances.join(', ')
+ : '—'}
+ |
+
+ {status === 'reçue' ? '✓ Reçue' : '✗ Manquante'}
+ |
+
+ );
+ })}
@@ -673,6 +724,15 @@ export function LoginScreen(): JSX.Element {
+
{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
-
- {isCollecting
- ? 'En attente des signatures des autres appareils…'
- : isPublishing
- ? 'Publication…'
- : 'Publier la preuve'}
-
+
+
+ {isCollecting
+ ? 'En attente des signatures des autres appareils…'
+ : isPublishing
+ ? 'Publication…'
+ : 'Publier la preuve'}
+
+ {publishRelayStatus.length > 0 && (
+
+
Statut par relais
+
+
+
+ | Relais |
+ Statut |
+ Erreur |
+
+
+
+ {publishRelayStatus.map((status, idx) => (
+
+ | {status.endpoint} |
+ {status.ok ? '✓ OK' : '✗ Échec'} |
+ {status.error ?? '—'} |
+
+ ))}
+
+
+
+ )}
+
)}
{isCollecting && proof !== null && (
Réessayer
-
- Synchroniser
+ {
+ dispatch({ type: 'E_RESYNC' });
+ navigate('/sync');
+ }}
+ >
+ Resync
([]);
+ const [configs, setConfigs] = useState