RESTE_A_FAIRE, relay validation, verify proof structure, UserWallet diagnostic/sync
**Motivations:** - Documenter le reste à faire (userwallet, service-login-verify, api-relay, website-skeleton) - Renforcer la validation côté api-relay et service-login-verify - Ajouter écrans diagnostic et sync service, service notifications relais, contrat par défaut **Root causes:** - N/A (évolutions + correctifs ciblés) **Correctifs:** - api-relay: GET /:hash (keys, messages, signatures) rejette hash vide → 400 - service-login-verify: validation structure preuve (challenge.hash, nonce, timestamp, signatures), reason invalid_proof_structure **Evolutions:** - RESTE_A_FAIRE.md: vue d’ensemble et tâches par projet - UserWallet: DiagnosticScreen, ServiceSyncScreen, relayNotificationService (hash events, fetch, decrypt, graph), defaultContract, loginStateMachine, useChannel, loginPublish, LoginScreen, LoginCollectShare - website-skeleton: README étendu **Pages affectées:** - RESTE_A_FAIRE.md - api-relay: keys, messages, signatures - service-login-verify: types, verifyLoginProof - userwallet: App, DiagnosticScreen, LoginCollectShare, LoginScreen, ServiceSyncScreen, useChannel, loginStateMachine, relayNotificationService, defaultContract, loginPublish - website-skeleton: README
This commit is contained in:
parent
8208809f03
commit
f27345e0ba
202
RESTE_A_FAIRE.md
Normal file
202
RESTE_A_FAIRE.md
Normal file
@ -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`
|
||||||
@ -39,6 +39,10 @@ export function createKeysRouter(
|
|||||||
router.get('/:hash', (req: Request, res: Response): void => {
|
router.get('/:hash', (req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
const hash = req.params.hash as string;
|
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 stored = storage.getKeys(hash);
|
||||||
const keys: MsgCle[] = stored.map((k) => k.msg);
|
const keys: MsgCle[] = stored.map((k) => k.msg);
|
||||||
res.json(keys);
|
res.json(keys);
|
||||||
|
|||||||
@ -73,6 +73,10 @@ export function createMessagesRouter(
|
|||||||
router.get('/:hash', (req: Request, res: Response): void => {
|
router.get('/:hash', (req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
const hash = req.params.hash as string;
|
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);
|
const stored = storage.getMessage(hash);
|
||||||
if (stored === undefined) {
|
if (stored === undefined) {
|
||||||
res.status(404).json({ error: 'Message not found' });
|
res.status(404).json({ error: 'Message not found' });
|
||||||
|
|||||||
@ -17,6 +17,10 @@ export function createSignaturesRouter(
|
|||||||
router.get('/:hash', (req: Request, res: Response): void => {
|
router.get('/:hash', (req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
const hash = req.params.hash as string;
|
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 stored = storage.getSignatures(hash);
|
||||||
const signatures: MsgSignature[] = stored.map((s) => s.msg);
|
const signatures: MsgSignature[] = stored.map((s) => s.msg);
|
||||||
res.json(signatures);
|
res.json(signatures);
|
||||||
|
|||||||
@ -57,5 +57,5 @@ export interface NonceCacheLike {
|
|||||||
|
|
||||||
export interface VerifyLoginProofResult {
|
export interface VerifyLoginProofResult {
|
||||||
accept: boolean;
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,11 +47,61 @@ function verifySignaturesStrict(
|
|||||||
/**
|
/**
|
||||||
* Verify login proof: crypto, allowed pubkeys, timestamp window, nonce anti-replay.
|
* Verify login proof: crypto, allowed pubkeys, timestamp window, nonce anti-replay.
|
||||||
* Service must provide allowedPubkeys (from validators) and a NonceCache.
|
* 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(
|
export function verifyLoginProof(
|
||||||
proof: LoginProof,
|
proof: LoginProof,
|
||||||
ctx: VerifyLoginProofContext,
|
ctx: VerifyLoginProofContext,
|
||||||
): VerifyLoginProofResult {
|
): 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) {
|
if (ctx.allowedPubkeys.size === 0) {
|
||||||
return {
|
return {
|
||||||
accept: false,
|
accept: false,
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import { LoginScreen } from './components/LoginScreen';
|
|||||||
import { LoginSignScreen } from './components/LoginSignScreen';
|
import { LoginSignScreen } from './components/LoginSignScreen';
|
||||||
import { ServiceListScreen } from './components/ServiceListScreen';
|
import { ServiceListScreen } from './components/ServiceListScreen';
|
||||||
import { MemberSelectionScreen } from './components/MemberSelectionScreen';
|
import { MemberSelectionScreen } from './components/MemberSelectionScreen';
|
||||||
|
import { DiagnosticScreen } from './components/DiagnosticScreen';
|
||||||
|
import { ServiceSyncScreen } from './components/ServiceSyncScreen';
|
||||||
import { DataExportImportScreen } from './components/DataExportImportScreen';
|
import { DataExportImportScreen } from './components/DataExportImportScreen';
|
||||||
import { UnlockScreen } from './components/UnlockScreen';
|
import { UnlockScreen } from './components/UnlockScreen';
|
||||||
import { useChannel } from './hooks/useChannel';
|
import { useChannel } from './hooks/useChannel';
|
||||||
@ -45,6 +47,8 @@ function AppContent(): JSX.Element {
|
|||||||
<Route path="/sync" element={<SyncScreen />} />
|
<Route path="/sync" element={<SyncScreen />} />
|
||||||
<Route path="/services" element={<ServiceListScreen />} />
|
<Route path="/services" element={<ServiceListScreen />} />
|
||||||
<Route path="/select-member" element={<MemberSelectionScreen />} />
|
<Route path="/select-member" element={<MemberSelectionScreen />} />
|
||||||
|
<Route path="/diagnostic" element={<DiagnosticScreen />} />
|
||||||
|
<Route path="/service-sync" element={<ServiceSyncScreen />} />
|
||||||
<Route path="/data" element={<DataExportImportScreen />} />
|
<Route path="/data" element={<DataExportImportScreen />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
332
userwallet/src/components/DiagnosticScreen.tsx
Normal file
332
userwallet/src/components/DiagnosticScreen.tsx
Normal file
@ -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<DiagnosticResult | null>(null);
|
||||||
|
|
||||||
|
const handleDiagnose = async (): Promise<void> => {
|
||||||
|
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 (
|
||||||
|
<main>
|
||||||
|
<h1>Diagnostic hash / objet</h1>
|
||||||
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
||||||
|
<section aria-labelledby="hash-input">
|
||||||
|
<h2 id="hash-input">Hash à diagnostiquer</h2>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="hash-input-field">
|
||||||
|
Hash
|
||||||
|
<input
|
||||||
|
id="hash-input-field"
|
||||||
|
type="text"
|
||||||
|
value={hashInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHashInput(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="hash-value"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDiagnose}
|
||||||
|
disabled={isDiagnosing || hashInput.trim() === ''}
|
||||||
|
>
|
||||||
|
{isDiagnosing ? 'Diagnostic en cours...' : 'Diagnostiquer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{result !== null && (
|
||||||
|
<section aria-labelledby="diagnostic-result">
|
||||||
|
<h2 id="diagnostic-result">Résultat du diagnostic</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Hash:</strong> {result.hash}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Message trouvé:</strong> {result.messageFound ? '✓ Oui' : '✗ Non'}
|
||||||
|
</p>
|
||||||
|
{result.messageFound && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<strong>Message déchiffré:</strong>{' '}
|
||||||
|
{result.messageDecrypted ? '✓ Oui' : '✗ Non'}
|
||||||
|
</p>
|
||||||
|
{result.messageDecrypted && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<strong>Type:</strong> {result.messageType ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
{result.serviceUuid !== undefined && (
|
||||||
|
<p>
|
||||||
|
<strong>Service UUID:</strong> {result.serviceUuid.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<strong>Hash valide:</strong>{' '}
|
||||||
|
{result.hashValid ? '✓ Oui' : '✗ Non'}
|
||||||
|
</p>
|
||||||
|
{result.hashMismatch === true && (
|
||||||
|
<p style={{ color: 'red' }}>
|
||||||
|
<strong>Erreur:</strong> Hash recalculé ≠ hash déclaré
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<strong>Validateurs vérifiés:</strong>{' '}
|
||||||
|
{result.validatorsChecked ? '✓ Oui' : '—'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Message validé:</strong>{' '}
|
||||||
|
{result.messageValidated ? '✓ Oui' : '✗ Non'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<strong>Signatures trouvées:</strong> {result.signaturesFound}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Signatures valides:</strong> {result.signaturesValid}
|
||||||
|
</p>
|
||||||
|
{result.signatureErrors !== undefined &&
|
||||||
|
result.signatureErrors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>Erreurs de signature:</strong>
|
||||||
|
<ul>
|
||||||
|
{result.signatureErrors.map((err, idx) => (
|
||||||
|
<li key={idx} style={{ color: 'red' }}>
|
||||||
|
{err}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<strong>Clés trouvées:</strong> {result.keysFound}
|
||||||
|
</p>
|
||||||
|
{result.keysFound === 0 && result.messageFound && (
|
||||||
|
<p style={{ color: 'orange' }}>
|
||||||
|
<strong>Attention:</strong> Aucune clé trouvée - message
|
||||||
|
indéchiffrable
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{result.error !== undefined && (
|
||||||
|
<p>
|
||||||
|
<strong>Erreur:</strong> {result.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(): void => {
|
||||||
|
navigate('/');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -74,26 +74,27 @@ export function LoginCollectShare({
|
|||||||
pairUuid: string | undefined;
|
pairUuid: string | undefined;
|
||||||
clePublique: string | undefined;
|
clePublique: string | undefined;
|
||||||
status: 'manquante' | 'reçue' | 'valide' | 'invalide';
|
status: 'manquante' | 'reçue' | 'valide' | 'invalide';
|
||||||
|
signature?: string;
|
||||||
}> => {
|
}> => {
|
||||||
if (loginPath === null) {
|
if (loginPath === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectedPubkeys = new Set(
|
const allSignatures = [
|
||||||
collectedSignatures?.map((s) => s.cle_publique) ?? [],
|
...proof.signatures,
|
||||||
);
|
...(collectedSignatures ?? []),
|
||||||
const localSignatures = new Set(
|
];
|
||||||
proof.signatures.map((s) => s.cle_publique),
|
|
||||||
);
|
|
||||||
|
|
||||||
return loginPath.signatures_requises.map((req) => {
|
return loginPath.signatures_requises.map((req) => {
|
||||||
const hasCollected = req.cle_publique !== undefined &&
|
// Trouver la signature correspondante
|
||||||
collectedPubkeys.has(req.cle_publique);
|
const matchingSig = allSignatures.find(
|
||||||
const hasLocal = req.cle_publique !== undefined &&
|
(s) => s.cle_publique === req.cle_publique,
|
||||||
localSignatures.has(req.cle_publique);
|
);
|
||||||
|
|
||||||
let status: 'manquante' | 'reçue' | 'valide' | 'invalide';
|
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';
|
status = 'reçue';
|
||||||
} else {
|
} else {
|
||||||
status = 'manquante';
|
status = 'manquante';
|
||||||
@ -104,6 +105,7 @@ export function LoginCollectShare({
|
|||||||
pairUuid: req.pair_uuid,
|
pairUuid: req.pair_uuid,
|
||||||
clePublique: req.cle_publique,
|
clePublique: req.cle_publique,
|
||||||
status,
|
status,
|
||||||
|
signature: matchingSig?.signature,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -54,7 +54,11 @@ export function LoginScreen(): JSX.Element {
|
|||||||
const [collectedPublishStats, setCollectedPublishStats] = useState<{
|
const [collectedPublishStats, setCollectedPublishStats] = useState<{
|
||||||
successCount: number;
|
successCount: number;
|
||||||
relaysCount: number;
|
relaysCount: number;
|
||||||
|
relayStatus?: Array<{ endpoint: string; ok: boolean; error?: string }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [publishRelayStatus, setPublishRelayStatus] = useState<
|
||||||
|
Array<{ endpoint: string; ok: boolean; error?: string }>
|
||||||
|
>([]);
|
||||||
const [collectProgressState, setCollectProgressState] = useState<{
|
const [collectProgressState, setCollectProgressState] = useState<{
|
||||||
satisfied: number;
|
satisfied: number;
|
||||||
required: 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<void> => {
|
const handlePublish = async (): Promise<void> => {
|
||||||
if (identity === null || proof === null) {
|
if (identity === null || proof === null) {
|
||||||
handleError('Identité ou preuve manquante', 'MISSING_REQUIREMENTS');
|
handleError('Identité ou preuve manquante', 'MISSING_REQUIREMENTS');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anti-rejeu: vérifier timestamp dans la fenêtre
|
||||||
if (!verifyTimestamp(proof.challenge.timestamp)) {
|
if (!verifyTimestamp(proof.challenge.timestamp)) {
|
||||||
handleError(
|
handleError(
|
||||||
'Timestamp hors fenêtre de validité (anti-rejeu)',
|
'Timestamp hors fenêtre de validité (anti-rejeu)',
|
||||||
@ -215,6 +226,7 @@ export function LoginScreen(): JSX.Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anti-rejeu: vérifier nonce unique
|
||||||
await nonceStore.init();
|
await nonceStore.init();
|
||||||
if (nonceStore.hasUsed(proof.challenge.nonce)) {
|
if (nonceStore.hasUsed(proof.challenge.nonce)) {
|
||||||
handleError('Nonce déjà utilisé (anti-rejeu)', 'X_NONCE_REUSED');
|
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 msgChiffre = loginBuilder.challengeToMsgChiffre(proof.challenge);
|
||||||
const msgCle = loginBuilder.challengeToMsgCle(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,
|
relays,
|
||||||
msgChiffre,
|
msgChiffre,
|
||||||
proof.signatures,
|
proof.signatures,
|
||||||
msgCle,
|
msgCle,
|
||||||
);
|
);
|
||||||
if (successCount === 0) {
|
setPublishRelayStatus(publishResult.relayStatus);
|
||||||
|
if (publishResult.successCount === 0) {
|
||||||
handleError('Échec de la publication sur tous les relais', 'PUBLISH_FAILED');
|
handleError('Échec de la publication sur tous les relais', 'PUBLISH_FAILED');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collecte des signatures distantes (mFA avec 2 devices)
|
||||||
let merged = proof.signatures;
|
let merged = proof.signatures;
|
||||||
if (loginPath !== null) {
|
if (loginPath !== null) {
|
||||||
setIsCollecting(true);
|
setIsCollecting(true);
|
||||||
@ -256,6 +271,7 @@ export function LoginScreen(): JSX.Element {
|
|||||||
loginPath.pairs_attendus,
|
loginPath.pairs_attendus,
|
||||||
);
|
);
|
||||||
const endpoints = relays.map((r) => r.endpoint);
|
const endpoints = relays.map((r) => r.endpoint);
|
||||||
|
// Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout
|
||||||
merged = await runCollectLoop(
|
merged = await runCollectLoop(
|
||||||
endpoints,
|
endpoints,
|
||||||
proof.challenge.hash,
|
proof.challenge.hash,
|
||||||
@ -281,14 +297,16 @@ export function LoginScreen(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si des signatures distantes ont été collectées, demander confirmation utilisateur
|
||||||
if (
|
if (
|
||||||
loginPath !== null &&
|
loginPath !== null &&
|
||||||
hasRemoteSignatures(merged, loginPath.pairs_attendus)
|
hasRemoteSignatures(merged, loginPath.pairs_attendus)
|
||||||
) {
|
) {
|
||||||
setCollectedMerged(merged);
|
setCollectedMerged(merged);
|
||||||
setCollectedPublishStats({
|
setCollectedPublishStats({
|
||||||
successCount,
|
successCount: publishResult.successCount,
|
||||||
relaysCount: relays.length,
|
relaysCount: relays.length,
|
||||||
|
relayStatus: publishResult.relayStatus,
|
||||||
});
|
});
|
||||||
setAwaitingRemoteAccept(true);
|
setAwaitingRemoteAccept(true);
|
||||||
setIsPublishing(false);
|
setIsPublishing(false);
|
||||||
@ -297,7 +315,8 @@ export function LoginScreen(): JSX.Element {
|
|||||||
|
|
||||||
const finalProof = await loginBuilder.buildProof(proof.challenge, merged);
|
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 verificationResults: string[] = [];
|
||||||
const verificationErrors: string[] = [];
|
const verificationErrors: string[] = [];
|
||||||
let verificationSuccess = true;
|
let verificationSuccess = true;
|
||||||
@ -361,10 +380,19 @@ export function LoginScreen(): JSX.Element {
|
|||||||
|
|
||||||
// Si vérification échoue, arrêter ici
|
// Si vérification échoue, arrêter ici
|
||||||
if (!verificationSuccess) {
|
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,
|
results: verificationResults,
|
||||||
errors: verificationErrors,
|
errors: verificationErrors,
|
||||||
});
|
signaturesCount: merged.length,
|
||||||
|
loginPathStatus: loginPath?.statut,
|
||||||
|
};
|
||||||
|
console.error('[Login] Verification failed:', verificationLog);
|
||||||
handleError(
|
handleError(
|
||||||
`Vérification locale échouée: ${verificationErrors.join('; ')}`,
|
`Vérification locale échouée: ${verificationErrors.join('; ')}`,
|
||||||
'VERIFICATION_FAILED',
|
'VERIFICATION_FAILED',
|
||||||
@ -373,15 +401,24 @@ export function LoginScreen(): JSX.Element {
|
|||||||
return;
|
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);
|
await nonceStore.markUsed(proof.challenge.nonce, proof.challenge.timestamp);
|
||||||
|
|
||||||
const updatedProof = { ...finalProof, statut: 'publie' as const };
|
const updatedProof = { ...finalProof, statut: 'publie' as const };
|
||||||
setProof(updatedProof);
|
setProof(updatedProof);
|
||||||
sendLoginProof(updatedProof);
|
sendLoginProof(updatedProof);
|
||||||
|
|
||||||
if (successCount < relays.length) {
|
if (publishResult.successCount < relays.length) {
|
||||||
handleError(
|
handleError(
|
||||||
`Publication partielle: ${successCount}/${relays.length} relais`,
|
`Publication partielle: ${publishResult.successCount}/${relays.length} relais`,
|
||||||
'PARTIAL_PUBLISH',
|
'PARTIAL_PUBLISH',
|
||||||
);
|
);
|
||||||
dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' });
|
dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' });
|
||||||
@ -601,10 +638,20 @@ export function LoginScreen(): JSX.Element {
|
|||||||
<th>Clé publique</th>
|
<th>Clé publique</th>
|
||||||
<th>Cardinalité min.</th>
|
<th>Cardinalité min.</th>
|
||||||
<th>Dépendances</th>
|
<th>Dépendances</th>
|
||||||
|
<th>Statut</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loginPath.signatures_requises.map((req, idx) => (
|
{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}>
|
<tr key={idx}>
|
||||||
<td>{req.membre_uuid.slice(0, 8)}...</td>
|
<td>{req.membre_uuid.slice(0, 8)}...</td>
|
||||||
<td>
|
<td>
|
||||||
@ -628,8 +675,12 @@ export function LoginScreen(): JSX.Element {
|
|||||||
? req.dependances.join(', ')
|
? req.dependances.join(', ')
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{status === 'reçue' ? '✓ Reçue' : '✗ Manquante'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -673,6 +724,15 @@ export function LoginScreen(): JSX.Element {
|
|||||||
<button type="button" onClick={handleAddPair}>
|
<button type="button" onClick={handleAddPair}>
|
||||||
Ajouter un pair
|
Ajouter un pair
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch({ type: 'E_OPEN_DIAGNOSTIC' });
|
||||||
|
navigate('/diagnostic');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diagnostic avancé
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
@ -686,6 +746,39 @@ export function LoginScreen(): JSX.Element {
|
|||||||
</section>
|
</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' && (
|
{showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (
|
||||||
<section aria-label="Reprise">
|
<section aria-label="Reprise">
|
||||||
<button type="button" onClick={handleSyncNow}>
|
<button type="button" onClick={handleSyncNow}>
|
||||||
@ -734,6 +827,7 @@ export function LoginScreen(): JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
|
{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
|
||||||
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
@ -745,6 +839,30 @@ export function LoginScreen(): JSX.Element {
|
|||||||
? 'Publication…'
|
? 'Publication…'
|
||||||
: 'Publier la preuve'}
|
: 'Publier la preuve'}
|
||||||
</button>
|
</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 && (
|
{isCollecting && proof !== null && (
|
||||||
<LoginCollectShare
|
<LoginCollectShare
|
||||||
@ -900,8 +1018,14 @@ export function LoginScreen(): JSX.Element {
|
|||||||
>
|
>
|
||||||
Réessayer
|
Réessayer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={handleSyncNow}>
|
<button
|
||||||
Synchroniser
|
type="button"
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch({ type: 'E_RESYNC' });
|
||||||
|
navigate('/sync');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resync
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
316
userwallet/src/components/ServiceSyncScreen.tsx
Normal file
316
userwallet/src/components/ServiceSyncScreen.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
|
import { useErrorHandler } from '../hooks/useErrorHandler';
|
||||||
|
import { ErrorDisplay } from './ErrorDisplay';
|
||||||
|
import { getStoredRelays } from '../utils/relay';
|
||||||
|
import { GraphResolver } from '../services/graphResolver';
|
||||||
|
import { SyncService } from '../services/syncService';
|
||||||
|
import type { ServiceStatus } from '../types/identity';
|
||||||
|
|
||||||
|
interface ServiceSyncConfig {
|
||||||
|
service_uuid: string;
|
||||||
|
enabled: boolean;
|
||||||
|
frequency: 'min' | 'hour' | 'day';
|
||||||
|
windowHours: number;
|
||||||
|
useBloom: boolean;
|
||||||
|
useMerkle: boolean;
|
||||||
|
lastSync?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceSyncScreen(): JSX.Element {
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
const { error, handleError, clearError } = useErrorHandler();
|
||||||
|
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||||
|
const [configs, setConfigs] = useState<Map<string, ServiceSyncConfig>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [syncResults, setSyncResults] = useState<
|
||||||
|
Map<string, { ok: boolean; message?: string }>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (identity === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphResolver = new GraphResolver();
|
||||||
|
const syncService = new SyncService(
|
||||||
|
getStoredRelays().filter((r) => r.enabled),
|
||||||
|
graphResolver,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
await syncService.init();
|
||||||
|
const svcs = graphResolver.getServices();
|
||||||
|
setServices(svcs.map((s) => ({ service_uuid: s.uuid })));
|
||||||
|
|
||||||
|
// Load configs from localStorage
|
||||||
|
const stored = localStorage.getItem('userwallet_service_sync_configs');
|
||||||
|
if (stored !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored) as Record<
|
||||||
|
string,
|
||||||
|
ServiceSyncConfig
|
||||||
|
>;
|
||||||
|
setConfigs(new Map(Object.entries(parsed)));
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid stored configs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [identity]);
|
||||||
|
|
||||||
|
const saveConfigs = useCallback((newConfigs: Map<string, ServiceSyncConfig>): void => {
|
||||||
|
const obj = Object.fromEntries(newConfigs);
|
||||||
|
localStorage.setItem('userwallet_service_sync_configs', JSON.stringify(obj));
|
||||||
|
setConfigs(newConfigs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getConfig = useCallback(
|
||||||
|
(serviceUuid: string): ServiceSyncConfig => {
|
||||||
|
const existing = configs.get(serviceUuid);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
service_uuid: serviceUuid,
|
||||||
|
enabled: false,
|
||||||
|
frequency: 'hour',
|
||||||
|
windowHours: 24,
|
||||||
|
useBloom: true,
|
||||||
|
useMerkle: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[configs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(serviceUuid: string, updates: Partial<ServiceSyncConfig>): void => {
|
||||||
|
const current = getConfig(serviceUuid);
|
||||||
|
const updated = { ...current, ...updates };
|
||||||
|
const newConfigs = new Map(configs);
|
||||||
|
newConfigs.set(serviceUuid, updated);
|
||||||
|
saveConfigs(newConfigs);
|
||||||
|
},
|
||||||
|
[configs, getConfig, saveConfigs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSyncNow = useCallback(
|
||||||
|
async (serviceUuid: string): Promise<void> => {
|
||||||
|
if (identity === null) {
|
||||||
|
handleError('Identité requise', 'NO_IDENTITY');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getConfig(serviceUuid);
|
||||||
|
const now = Date.now();
|
||||||
|
const start = now - config.windowHours * 3600000;
|
||||||
|
|
||||||
|
const graphResolver = new GraphResolver();
|
||||||
|
const syncService = new SyncService(
|
||||||
|
getStoredRelays().filter((r) => r.enabled),
|
||||||
|
graphResolver,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
await syncService.init();
|
||||||
|
|
||||||
|
const result = await syncService.sync(start, now, serviceUuid);
|
||||||
|
|
||||||
|
const newResults = new Map(syncResults);
|
||||||
|
newResults.set(serviceUuid, {
|
||||||
|
ok: result.ok,
|
||||||
|
message: result.ok
|
||||||
|
? `Sync OK: ${result.newMessages} nouveaux messages`
|
||||||
|
: 'Erreur lors de la synchronisation',
|
||||||
|
});
|
||||||
|
setSyncResults(newResults);
|
||||||
|
|
||||||
|
// Update last sync
|
||||||
|
updateConfig(serviceUuid, { lastSync: now });
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors de la synchronisation');
|
||||||
|
const newResults = new Map(syncResults);
|
||||||
|
newResults.set(serviceUuid, {
|
||||||
|
ok: false,
|
||||||
|
message: err instanceof Error ? err.message : 'Erreur inconnue',
|
||||||
|
});
|
||||||
|
setSyncResults(newResults);
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[identity, getConfig, syncResults, updateConfig, handleError, clearError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFrequencyMs = (frequency: 'min' | 'hour' | 'day'): number => {
|
||||||
|
switch (frequency) {
|
||||||
|
case 'min':
|
||||||
|
return 60000;
|
||||||
|
case 'hour':
|
||||||
|
return 3600000;
|
||||||
|
case 'day':
|
||||||
|
return 86400000;
|
||||||
|
default:
|
||||||
|
return 3600000;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Synchronisation continue par service</h1>
|
||||||
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
||||||
|
|
||||||
|
<section aria-labelledby="services-list">
|
||||||
|
<h2 id="services-list">Services</h2>
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<p>Aucun service disponible. Effectuez une synchronisation globale d'abord.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Service UUID</th>
|
||||||
|
<th>Sync auto</th>
|
||||||
|
<th>Fréquence</th>
|
||||||
|
<th>Fenêtre (h)</th>
|
||||||
|
<th>Bloom</th>
|
||||||
|
<th>Merkle</th>
|
||||||
|
<th>Dernière sync</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{services.map((svc) => {
|
||||||
|
const config = getConfig(svc.service_uuid);
|
||||||
|
const result = syncResults.get(svc.service_uuid);
|
||||||
|
return (
|
||||||
|
<tr key={svc.service_uuid}>
|
||||||
|
<td>{svc.service_uuid.slice(0, 16)}...</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.enabled}
|
||||||
|
onChange={(e): void => {
|
||||||
|
updateConfig(svc.service_uuid, {
|
||||||
|
enabled: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={config.frequency}
|
||||||
|
onChange={(e): void => {
|
||||||
|
updateConfig(svc.service_uuid, {
|
||||||
|
frequency: e.target.value as 'min' | 'hour' | 'day',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="min">Minute</option>
|
||||||
|
<option value="hour">Heure</option>
|
||||||
|
<option value="day">Jour</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={config.windowHours}
|
||||||
|
onChange={(e): void => {
|
||||||
|
updateConfig(svc.service_uuid, {
|
||||||
|
windowHours: parseInt(e.target.value, 10) || 24,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.useBloom}
|
||||||
|
onChange={(e): void => {
|
||||||
|
updateConfig(svc.service_uuid, {
|
||||||
|
useBloom: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.useMerkle}
|
||||||
|
onChange={(e): void => {
|
||||||
|
updateConfig(svc.service_uuid, {
|
||||||
|
useMerkle: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{config.useMerkle && (
|
||||||
|
<span style={{ color: 'orange', fontSize: '0.8em' }}>
|
||||||
|
{' '}
|
||||||
|
(Non implémenté)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{config.lastSync !== undefined
|
||||||
|
? new Date(config.lastSync).toLocaleString()
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(): void => {
|
||||||
|
void handleSyncNow(svc.service_uuid);
|
||||||
|
}}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
{isSyncing ? 'Sync...' : 'Lancer maintenant'}
|
||||||
|
</button>
|
||||||
|
{result !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8em',
|
||||||
|
color: result.ok ? 'green' : 'red',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="info">
|
||||||
|
<h2 id="info">Informations</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Bloom filter</strong> : Réduit les requêtes inutiles en
|
||||||
|
évitant de fetch des clés pour des hash déjà connus.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Merkle trees</strong> : Accélération de scan pour volumes
|
||||||
|
importants (non implémenté).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fenêtre</strong> : Période de scan en heures (1-168).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fréquence</strong> : Intervalle entre synchronisations
|
||||||
|
automatiques.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,12 +11,23 @@ import { useIdentity } from './useIdentity';
|
|||||||
import { signMessage, generateChallenge } from '../utils/crypto';
|
import { signMessage, generateChallenge } from '../utils/crypto';
|
||||||
import { GraphResolver } from '../services/graphResolver';
|
import { GraphResolver } from '../services/graphResolver';
|
||||||
import { updateGraphFromMessage } from '../services/syncUpdateGraph';
|
import { updateGraphFromMessage } from '../services/syncUpdateGraph';
|
||||||
|
import {
|
||||||
|
loadDefaultContract,
|
||||||
|
hasDefaultContract,
|
||||||
|
} from '../utils/defaultContract';
|
||||||
import type { LoginProof } from '../types/identity';
|
import type { LoginProof } from '../types/identity';
|
||||||
import type { Contrat, Action } from '../types/contract';
|
import type { Contrat, Action } from '../types/contract';
|
||||||
|
|
||||||
export function useChannel() {
|
export function useChannel() {
|
||||||
const { identity } = useIdentity();
|
const { identity } = useIdentity();
|
||||||
const graphResolver = useState(() => new GraphResolver())[0];
|
const graphResolver = useState(() => {
|
||||||
|
const resolver = new GraphResolver();
|
||||||
|
// Load default contract if in iframe and no contract received yet
|
||||||
|
if (isInIframe()) {
|
||||||
|
loadDefaultContract(resolver);
|
||||||
|
}
|
||||||
|
return resolver;
|
||||||
|
})[0];
|
||||||
|
|
||||||
const handleAuthRequest = useCallback(
|
const handleAuthRequest = useCallback(
|
||||||
(_message: AuthRequestMessage): void => {
|
(_message: AuthRequestMessage): void => {
|
||||||
@ -52,11 +63,25 @@ export function useChannel() {
|
|||||||
const handleContract = useCallback(
|
const handleContract = useCallback(
|
||||||
(message: ContractMessage): void => {
|
(message: ContractMessage): void => {
|
||||||
const payload = message.payload;
|
const payload = message.payload;
|
||||||
|
let hasValidContract = false;
|
||||||
|
|
||||||
if (payload?.contrat !== undefined) {
|
if (payload?.contrat !== undefined) {
|
||||||
// Valider et ajouter le contrat principal
|
// Valider et ajouter le contrat principal
|
||||||
try {
|
try {
|
||||||
const contrat = payload.contrat as Contrat;
|
const contrat = payload.contrat as Contrat;
|
||||||
|
if (
|
||||||
|
typeof contrat.uuid === 'string' &&
|
||||||
|
typeof contrat.version === 'string' &&
|
||||||
|
typeof contrat.validateurs === 'object' &&
|
||||||
|
contrat.validateurs !== null
|
||||||
|
) {
|
||||||
updateGraphFromMessage(contrat, graphResolver);
|
updateGraphFromMessage(contrat, graphResolver);
|
||||||
|
hasValidContract = true;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'Invalid contract structure received via channel: missing required fields',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error processing contract from channel:', err);
|
console.error('Error processing contract from channel:', err);
|
||||||
}
|
}
|
||||||
@ -67,7 +92,16 @@ export function useChannel() {
|
|||||||
for (const cf of payload.contrats_fils) {
|
for (const cf of payload.contrats_fils) {
|
||||||
try {
|
try {
|
||||||
const contrat = cf as Contrat;
|
const contrat = cf as Contrat;
|
||||||
|
if (
|
||||||
|
typeof contrat.uuid === 'string' &&
|
||||||
|
typeof contrat.version === 'string'
|
||||||
|
) {
|
||||||
updateGraphFromMessage(contrat, graphResolver);
|
updateGraphFromMessage(contrat, graphResolver);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'Invalid child contract structure received via channel',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error processing child contract from channel:', err);
|
console.error('Error processing child contract from channel:', err);
|
||||||
}
|
}
|
||||||
@ -79,12 +113,26 @@ export function useChannel() {
|
|||||||
for (const a of payload.actions) {
|
for (const a of payload.actions) {
|
||||||
try {
|
try {
|
||||||
const action = a as Action;
|
const action = a as Action;
|
||||||
|
if (
|
||||||
|
typeof action.uuid === 'string' &&
|
||||||
|
typeof action.validateurs_action === 'object' &&
|
||||||
|
action.validateurs_action !== null
|
||||||
|
) {
|
||||||
updateGraphFromMessage(action, graphResolver);
|
updateGraphFromMessage(action, graphResolver);
|
||||||
|
} else {
|
||||||
|
console.warn('Invalid action structure received via channel');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error processing action from channel:', err);
|
console.error('Error processing action from channel:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si un contrat valide a été reçu, on peut remplacer le contrat par défaut
|
||||||
|
if (hasValidContract && hasDefaultContract(graphResolver)) {
|
||||||
|
// Le contrat par défaut reste en cache mais le nouveau contrat prend le dessus
|
||||||
|
// pour les résolutions de graphe
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[graphResolver],
|
[graphResolver],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,7 +15,9 @@ export type LoginState =
|
|||||||
| 'S_LOGIN_VERIFY_LOCAL'
|
| 'S_LOGIN_VERIFY_LOCAL'
|
||||||
| 'S_LOGIN_SUCCESS'
|
| 'S_LOGIN_SUCCESS'
|
||||||
| 'S_LOGIN_FAILURE'
|
| 'S_LOGIN_FAILURE'
|
||||||
| 'S_ERROR_RECOVERABLE';
|
| 'S_ERROR_RECOVERABLE'
|
||||||
|
| 'S_ERROR_FATAL'
|
||||||
|
| 'S_DIAGNOSTIC';
|
||||||
|
|
||||||
export type LoginEvent =
|
export type LoginEvent =
|
||||||
| { type: 'E_SELECT_SERVICE'; serviceUuid: string }
|
| { type: 'E_SELECT_SERVICE'; serviceUuid: string }
|
||||||
@ -35,7 +37,10 @@ export type LoginEvent =
|
|||||||
| { type: 'E_DONE' }
|
| { type: 'E_DONE' }
|
||||||
| { type: 'E_RETRY' }
|
| { type: 'E_RETRY' }
|
||||||
| { type: 'E_SYNC_NOW' }
|
| { type: 'E_SYNC_NOW' }
|
||||||
| { type: 'E_ADD_PAIR' };
|
| { type: 'E_RESYNC' }
|
||||||
|
| { type: 'E_ADD_PAIR' }
|
||||||
|
| { type: 'E_OPEN_DIAGNOSTIC' }
|
||||||
|
| { type: 'E_EXIT' };
|
||||||
|
|
||||||
export interface TransitionResult {
|
export interface TransitionResult {
|
||||||
nextState: LoginState;
|
nextState: LoginState;
|
||||||
@ -159,6 +164,15 @@ export function transition(
|
|||||||
if (event.type === 'E_DONE') {
|
if (event.type === 'E_DONE') {
|
||||||
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
||||||
}
|
}
|
||||||
|
if (event.type === 'E_RETRY') {
|
||||||
|
return { nextState: 'S_LOGIN_BUILD_PATH' };
|
||||||
|
}
|
||||||
|
if (event.type === 'E_RESYNC') {
|
||||||
|
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
||||||
|
}
|
||||||
|
if (event.type === 'E_BACK') {
|
||||||
|
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'S_ERROR_RECOVERABLE':
|
case 'S_ERROR_RECOVERABLE':
|
||||||
@ -171,10 +185,26 @@ export function transition(
|
|||||||
if (event.type === 'E_ADD_PAIR') {
|
if (event.type === 'E_ADD_PAIR') {
|
||||||
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
||||||
}
|
}
|
||||||
|
if (event.type === 'E_OPEN_DIAGNOSTIC') {
|
||||||
|
return { nextState: 'S_DIAGNOSTIC' };
|
||||||
|
}
|
||||||
if (event.type === 'E_BACK') {
|
if (event.type === 'E_BACK') {
|
||||||
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'S_ERROR_FATAL':
|
||||||
|
if (event.type === 'E_EXIT') {
|
||||||
|
// État terminal - pas de transition possible
|
||||||
|
return { nextState: 'S_ERROR_FATAL' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'S_DIAGNOSTIC':
|
||||||
|
if (event.type === 'E_BACK') {
|
||||||
|
return { nextState: 'S_ERROR_RECOVERABLE' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nextState: state };
|
return { nextState: state };
|
||||||
@ -189,5 +219,9 @@ export function isTerminal(state: LoginState): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isErrorState(state: LoginState): boolean {
|
export function isErrorState(state: LoginState): boolean {
|
||||||
return state === 'S_ERROR_RECOVERABLE';
|
return state === 'S_ERROR_RECOVERABLE' || state === 'S_ERROR_FATAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFatalError(state: LoginState): boolean {
|
||||||
|
return state === 'S_ERROR_FATAL';
|
||||||
}
|
}
|
||||||
|
|||||||
292
userwallet/src/services/relayNotificationService.ts
Normal file
292
userwallet/src/services/relayNotificationService.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { getStoredRelays } from '../utils/relay';
|
||||||
|
import {
|
||||||
|
getMessageByHash,
|
||||||
|
getSignatures,
|
||||||
|
getKeys,
|
||||||
|
} from '../utils/relay';
|
||||||
|
import { tryDecryptWithKeys } from './syncDecrypt';
|
||||||
|
import { updateGraphFromMessage } from './syncUpdateGraph';
|
||||||
|
import { validateDecryptedMessage } from './syncValidate';
|
||||||
|
import type { LocalIdentity } from '../types/identity';
|
||||||
|
import type { RelayConfig } from '../types/identity';
|
||||||
|
import type { GraphResolver } from './graphResolver';
|
||||||
|
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitted when a new hash is detected on relays.
|
||||||
|
*/
|
||||||
|
export interface RelayHashEvent {
|
||||||
|
hash: string;
|
||||||
|
relay: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for hash events.
|
||||||
|
*/
|
||||||
|
export type HashEventListener = (event: RelayHashEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for processing a hash.
|
||||||
|
*/
|
||||||
|
export interface ProcessHashOptions {
|
||||||
|
fetchMessage?: boolean;
|
||||||
|
fetchSignatures?: boolean;
|
||||||
|
fetchKeys?: boolean;
|
||||||
|
decryptAndUpdateGraph?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of processing a hash.
|
||||||
|
*/
|
||||||
|
export interface ProcessHashResult {
|
||||||
|
hash: string;
|
||||||
|
messageFetched: boolean;
|
||||||
|
messageDecrypted: boolean;
|
||||||
|
graphUpdated: boolean;
|
||||||
|
signaturesFetched: number;
|
||||||
|
keysFetched: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling relay notifications and automatic fetching.
|
||||||
|
* Supports pull-based model (polling) and can be extended for push (WebSocket).
|
||||||
|
*/
|
||||||
|
export class RelayNotificationService {
|
||||||
|
private readonly graphResolver: GraphResolver;
|
||||||
|
private readonly identity: LocalIdentity | null;
|
||||||
|
private hashListeners: Set<HashEventListener> = new Set();
|
||||||
|
private isPolling: boolean = false;
|
||||||
|
private pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
graphResolver: GraphResolver,
|
||||||
|
identity?: LocalIdentity | null,
|
||||||
|
) {
|
||||||
|
this.graphResolver = graphResolver;
|
||||||
|
this.identity = identity ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a listener for hash events.
|
||||||
|
*/
|
||||||
|
addHashListener(listener: HashEventListener): void {
|
||||||
|
this.hashListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a hash listener.
|
||||||
|
*/
|
||||||
|
removeHashListener(listener: HashEventListener): void {
|
||||||
|
this.hashListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a hash event to all listeners.
|
||||||
|
*/
|
||||||
|
private emitHashEvent(event: RelayHashEvent): void {
|
||||||
|
for (const listener of this.hashListeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in hash event listener:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a hash: fetch message, signatures, keys, decrypt and update graph.
|
||||||
|
*/
|
||||||
|
async processHash(
|
||||||
|
hash: string,
|
||||||
|
options: ProcessHashOptions = {},
|
||||||
|
): Promise<ProcessHashResult> {
|
||||||
|
const {
|
||||||
|
fetchMessage = true,
|
||||||
|
fetchSignatures = true,
|
||||||
|
fetchKeys = true,
|
||||||
|
decryptAndUpdateGraph = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const result: ProcessHashResult = {
|
||||||
|
hash,
|
||||||
|
messageFetched: false,
|
||||||
|
messageDecrypted: false,
|
||||||
|
graphUpdated: false,
|
||||||
|
signaturesFetched: 0,
|
||||||
|
keysFetched: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const relays = getStoredRelays().filter((r) => r.enabled);
|
||||||
|
if (relays.length === 0) {
|
||||||
|
result.error = 'No enabled relays';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch message
|
||||||
|
let msgChiffre: MsgChiffre | null = null;
|
||||||
|
if (fetchMessage) {
|
||||||
|
for (const relay of relays) {
|
||||||
|
try {
|
||||||
|
msgChiffre = await getMessageByHash(relay.endpoint, hash);
|
||||||
|
result.messageFetched = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch signatures
|
||||||
|
if (fetchSignatures) {
|
||||||
|
for (const relay of relays) {
|
||||||
|
try {
|
||||||
|
const sigs = await getSignatures(relay.endpoint, hash);
|
||||||
|
result.signaturesFetched += sigs.length;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch keys
|
||||||
|
let allKeys: MsgCle[] = [];
|
||||||
|
if (fetchKeys) {
|
||||||
|
for (const relay of relays) {
|
||||||
|
try {
|
||||||
|
const keys = await getKeys(relay.endpoint, hash);
|
||||||
|
allKeys.push(...keys);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.keysFetched = allKeys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and update graph
|
||||||
|
if (
|
||||||
|
decryptAndUpdateGraph &&
|
||||||
|
msgChiffre !== null &&
|
||||||
|
this.identity !== null &&
|
||||||
|
allKeys.length > 0
|
||||||
|
) {
|
||||||
|
const decrypted = await tryDecryptWithKeys(
|
||||||
|
msgChiffre,
|
||||||
|
allKeys,
|
||||||
|
this.identity,
|
||||||
|
);
|
||||||
|
if (decrypted !== null) {
|
||||||
|
result.messageDecrypted = true;
|
||||||
|
|
||||||
|
// Validate message
|
||||||
|
const isValid = await validateDecryptedMessage(
|
||||||
|
decrypted,
|
||||||
|
async (h: string): Promise<MsgSignature[]> => {
|
||||||
|
const allSigs: MsgSignature[] = [];
|
||||||
|
for (const relay of relays) {
|
||||||
|
try {
|
||||||
|
const sigs = await getSignatures(relay.endpoint, h);
|
||||||
|
allSigs.push(...sigs);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allSigs;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
updateGraphFromMessage(decrypted, this.graphResolver);
|
||||||
|
result.graphUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.error =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for new hashes (pull-based).
|
||||||
|
* Polls keys in window to detect new hashes.
|
||||||
|
*/
|
||||||
|
startPolling(
|
||||||
|
intervalMs: number = 60000,
|
||||||
|
windowStart?: number,
|
||||||
|
windowEnd?: number,
|
||||||
|
): void {
|
||||||
|
if (this.isPolling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPolling = true;
|
||||||
|
const start = windowStart ?? Date.now() - 3600000; // 1 hour default
|
||||||
|
const end = windowEnd ?? Date.now();
|
||||||
|
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const relays = getStoredRelays().filter((r) => r.enabled);
|
||||||
|
for (const relay of relays) {
|
||||||
|
try {
|
||||||
|
const { getKeysInWindow } = await import('../utils/relay');
|
||||||
|
const keys = await getKeysInWindow(relay.endpoint, start, end);
|
||||||
|
for (const key of keys) {
|
||||||
|
this.emitHashEvent({
|
||||||
|
hash: key.hash_message,
|
||||||
|
relay: relay.endpoint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error polling relay ${relay.endpoint}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in polling loop:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial poll
|
||||||
|
void poll();
|
||||||
|
|
||||||
|
// Periodic polling
|
||||||
|
this.pollingInterval = setInterval(() => {
|
||||||
|
void poll();
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop polling.
|
||||||
|
*/
|
||||||
|
stopPolling(): void {
|
||||||
|
if (this.pollingInterval !== null) {
|
||||||
|
clearInterval(this.pollingInterval);
|
||||||
|
this.pollingInterval = null;
|
||||||
|
}
|
||||||
|
this.isPolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger processing of a hash (e.g., from push notification).
|
||||||
|
*/
|
||||||
|
async triggerHashProcessing(
|
||||||
|
hash: string,
|
||||||
|
relay: string,
|
||||||
|
options?: ProcessHashOptions,
|
||||||
|
): Promise<ProcessHashResult> {
|
||||||
|
// Emit event first
|
||||||
|
this.emitHashEvent({
|
||||||
|
hash,
|
||||||
|
relay,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process hash
|
||||||
|
return await this.processHash(hash, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
userwallet/src/utils/defaultContract.ts
Normal file
143
userwallet/src/utils/defaultContract.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import type { Contrat, Action, Service } from '../types/contract';
|
||||||
|
import { GraphResolver } from '../services/graphResolver';
|
||||||
|
import { updateGraphFromMessage } from '../services/syncUpdateGraph';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default contract configuration (placeholder).
|
||||||
|
* Used when no contract is received via channel message.
|
||||||
|
* Should be replaced with actual default contract in production.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CONTRACT: Contrat = {
|
||||||
|
uuid: 'default-contract-uuid',
|
||||||
|
version: '1.0.0',
|
||||||
|
types: {
|
||||||
|
types_uuid: ['default-contract-type-uuid'],
|
||||||
|
types_names_chiffres: 'contrat',
|
||||||
|
},
|
||||||
|
validateurs: {
|
||||||
|
membres_du_role: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'default-member-uuid',
|
||||||
|
signatures_obligatoires: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'default-member-uuid',
|
||||||
|
cle_publique: '02' + '0'.repeat(64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
datajson: {
|
||||||
|
services_uuid: ['default-service-uuid'],
|
||||||
|
types_uuid: ['default-contract-type-uuid'],
|
||||||
|
label: 'Contrat par défaut',
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
liste_relais: [],
|
||||||
|
version_logicielle: '1.0.0',
|
||||||
|
hash: {
|
||||||
|
algo: 'sha256',
|
||||||
|
hash_value: '',
|
||||||
|
},
|
||||||
|
signatures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default login action (placeholder).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LOGIN_ACTION: Action = {
|
||||||
|
uuid: 'default-login-action-uuid',
|
||||||
|
version: '1.0.0',
|
||||||
|
types: {
|
||||||
|
types_uuid: ['default-action-type-uuid'],
|
||||||
|
types_names_chiffres: 'action,login',
|
||||||
|
},
|
||||||
|
validateurs: {
|
||||||
|
membres_du_role: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'default-member-uuid',
|
||||||
|
signatures_obligatoires: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'default-member-uuid',
|
||||||
|
cle_publique: '02' + '0'.repeat(64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
validateurs_action: {
|
||||||
|
membres_du_role: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'default-member-uuid',
|
||||||
|
signatures_obligatoires: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'default-member-uuid',
|
||||||
|
cle_publique: '02' + '0'.repeat(64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contrats_parents_uuid: ['default-contract-uuid'],
|
||||||
|
datajson: {
|
||||||
|
services_uuid: ['default-service-uuid'],
|
||||||
|
types_uuid: ['default-action-type-uuid'],
|
||||||
|
label: 'Action login par défaut',
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
liste_relais: [],
|
||||||
|
version_logicielle: '1.0.0',
|
||||||
|
hash: {
|
||||||
|
algo: 'sha256',
|
||||||
|
hash_value: '',
|
||||||
|
},
|
||||||
|
signatures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default service (placeholder).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SERVICE: Service = {
|
||||||
|
uuid: 'default-service-uuid',
|
||||||
|
version: '1.0.0',
|
||||||
|
types: {
|
||||||
|
types_uuid: ['default-service-type-uuid'],
|
||||||
|
types_names_chiffres: 'service',
|
||||||
|
},
|
||||||
|
contrat_uuid: 'default-contract-uuid',
|
||||||
|
datajson: {
|
||||||
|
services_uuid: ['default-service-uuid'],
|
||||||
|
types_uuid: ['default-service-type-uuid'],
|
||||||
|
label: 'Service par défaut',
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
liste_relais: [],
|
||||||
|
version_logicielle: '1.0.0',
|
||||||
|
hash: {
|
||||||
|
algo: 'sha256',
|
||||||
|
hash_value: '',
|
||||||
|
},
|
||||||
|
signatures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load default contract into GraphResolver.
|
||||||
|
* Used when no contract is received via channel message.
|
||||||
|
*/
|
||||||
|
export function loadDefaultContract(graphResolver: GraphResolver): void {
|
||||||
|
try {
|
||||||
|
updateGraphFromMessage(DEFAULT_SERVICE, graphResolver);
|
||||||
|
updateGraphFromMessage(DEFAULT_CONTRACT, graphResolver);
|
||||||
|
updateGraphFromMessage(DEFAULT_LOGIN_ACTION, graphResolver);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading default contract:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if default contract is loaded in GraphResolver.
|
||||||
|
*/
|
||||||
|
export function hasDefaultContract(graphResolver: GraphResolver): boolean {
|
||||||
|
const services = graphResolver.getServices();
|
||||||
|
return services.some((s) => s.uuid === DEFAULT_SERVICE.uuid);
|
||||||
|
}
|
||||||
@ -3,22 +3,37 @@ import type { RelayConfig } from '../types/identity';
|
|||||||
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
|
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
|
||||||
import type { ProofSignature } from './collectSignatures';
|
import type { ProofSignature } from './collectSignatures';
|
||||||
|
|
||||||
|
export interface RelayPublishStatus {
|
||||||
|
endpoint: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
successCount: number;
|
||||||
|
relayStatus: RelayPublishStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish message + optional MsgCle + ourSigs to relays (best effort per relay).
|
* Publish message + optional MsgCle + ourSigs to relays (best effort per relay).
|
||||||
* When msgCle is provided (e.g. login ECDH), posts it for scan → fetch → decrypt flow.
|
* When msgCle is provided (e.g. login ECDH), posts it for scan → fetch → decrypt flow.
|
||||||
|
* Returns detailed status per relay.
|
||||||
*/
|
*/
|
||||||
export async function publishMessageAndSigs(
|
export async function publishMessageAndSigs(
|
||||||
relays: RelayConfig[],
|
relays: RelayConfig[],
|
||||||
msgChiffre: MsgChiffre,
|
msgChiffre: MsgChiffre,
|
||||||
ourSigs: ProofSignature[],
|
ourSigs: ProofSignature[],
|
||||||
msgCle?: MsgCle | null,
|
msgCle?: MsgCle | null,
|
||||||
): Promise<number> {
|
): Promise<PublishResult> {
|
||||||
let ok = 0;
|
const relayStatus: RelayPublishStatus[] = [];
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
for (const r of relays) {
|
for (const r of relays) {
|
||||||
if (!r.enabled) {
|
if (!r.enabled) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// Ordre strict : message → signatures → clés
|
||||||
await postMessageChiffre(r.endpoint, msgChiffre);
|
await postMessageChiffre(r.endpoint, msgChiffre);
|
||||||
if (msgCle !== undefined && msgCle !== null) {
|
if (msgCle !== undefined && msgCle !== null) {
|
||||||
await postKey(r.endpoint, msgCle);
|
await postKey(r.endpoint, msgCle);
|
||||||
@ -35,10 +50,19 @@ export async function publishMessageAndSigs(
|
|||||||
};
|
};
|
||||||
await postSignature(r.endpoint, msgSig);
|
await postSignature(r.endpoint, msgSig);
|
||||||
}
|
}
|
||||||
ok++;
|
relayStatus.push({ endpoint: r.endpoint, ok: true });
|
||||||
|
successCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const errorMsg =
|
||||||
|
err instanceof Error ? err.message : String(err);
|
||||||
console.error(`Publish to ${r.endpoint} failed:`, err);
|
console.error(`Publish to ${r.endpoint} failed:`, err);
|
||||||
|
relayStatus.push({
|
||||||
|
endpoint: r.endpoint,
|
||||||
|
ok: false,
|
||||||
|
error: errorMsg,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ok;
|
|
||||||
|
return { successCount, relayStatus };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,102 @@ Si aucun contrat n'est reçu, les `DEFAULT_VALIDATEURS` sont utilisés comme fal
|
|||||||
5. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l'iframe.
|
5. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l'iframe.
|
||||||
6. **Login** : depuis l'iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
|
6. **Login** : depuis l'iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
|
||||||
|
|
||||||
|
## Exemple d'intégration
|
||||||
|
|
||||||
|
### Envoi de contrat depuis le parent
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Depuis la page parente qui héberge l'iframe
|
||||||
|
const iframe = document.getElementById('userwallet');
|
||||||
|
const userwalletOrigin = 'https://userwallet.example.com';
|
||||||
|
|
||||||
|
// Envoyer le contrat avec l'action login
|
||||||
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'contract',
|
||||||
|
payload: {
|
||||||
|
contrat: {
|
||||||
|
uuid: 'contrat-uuid-123',
|
||||||
|
validateurs: {
|
||||||
|
membres_du_role: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'membre-uuid-456',
|
||||||
|
signatures_obligatoires: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'membre-uuid-456',
|
||||||
|
cle_publique: '02abc123...', // Clé publique secp256k1
|
||||||
|
cardinalite_minimale: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
datajson: {
|
||||||
|
types_names_chiffres: 'contrat'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contrats_fils: [], // Optionnel
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
uuid: 'action-login-uuid',
|
||||||
|
types: {
|
||||||
|
types_names_chiffres: 'action-login',
|
||||||
|
types_uuid: ['action-uuid']
|
||||||
|
},
|
||||||
|
validateurs_action: {
|
||||||
|
membres_du_role: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'membre-uuid-456',
|
||||||
|
signatures_obligatoires: [
|
||||||
|
{
|
||||||
|
membre_uuid: 'membre-uuid-456',
|
||||||
|
cle_publique: '02abc123...',
|
||||||
|
cardinalite_minimale: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}, userwalletOrigin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Réception et vérification de login-proof
|
||||||
|
|
||||||
|
Le skeleton écoute automatiquement les messages `login-proof` et vérifie la preuve :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Le skeleton fait automatiquement :
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'login-proof') {
|
||||||
|
const result = verifyLoginProof(event.data.payload, {
|
||||||
|
allowedPubkeys, // Construit depuis les validateurs
|
||||||
|
nonceCache, // Cache anti-rejeu
|
||||||
|
timestampWindowMs: 300000 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.accept) {
|
||||||
|
// Ouvrir la session utilisateur
|
||||||
|
console.log('Login accepté');
|
||||||
|
} else {
|
||||||
|
// Refuser le login
|
||||||
|
console.error('Login refusé:', result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestion des erreurs
|
||||||
|
|
||||||
|
Les raisons de refus possibles :
|
||||||
|
- `invalid_proof_structure` : Structure de la preuve invalide
|
||||||
|
- `timestamp_out_of_window` : Timestamp hors fenêtre (défaut ±5 min)
|
||||||
|
- `nonce_reused` : Nonce déjà utilisé (anti-rejeu)
|
||||||
|
- `validators_not_verifiable` : Aucune clé publique dans les validateurs
|
||||||
|
- `no_validator_signature` : Aucune signature valide de validateurs
|
||||||
|
- `signature_cle_publique_not_authorized` : Signature avec clé non autorisée
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `index.html` : page avec iframe, zone de statut, bouton auth.
|
- `index.html` : page avec iframe, zone de statut, bouton auth.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user