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:
ncantu 2026-01-28 01:37:16 +01:00
parent 8208809f03
commit f27345e0ba
17 changed files with 1749 additions and 70 deletions

202
RESTE_A_FAIRE.md Normal file
View 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`

View File

@ -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);

View File

@ -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' });

View File

@ -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);

View File

@ -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';
}

View File

@ -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,

View File

@ -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 {
<Route path="/sync" element={<SyncScreen />} />
<Route path="/services" element={<ServiceListScreen />} />
<Route path="/select-member" element={<MemberSelectionScreen />} />
<Route path="/diagnostic" element={<DiagnosticScreen />} />
<Route path="/service-sync" element={<ServiceSyncScreen />} />
<Route path="/data" element={<DataExportImportScreen />} />
</Routes>
);

View 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>
);
}

View File

@ -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,
};
});
};

View File

@ -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<void> => {
if (identity === null || proof === null) {
handleError('Identité ou preuve manquante', 'MISSING_REQUIREMENTS');
return;
}
// Anti-rejeu: vérifier timestamp dans la fenêtre
if (!verifyTimestamp(proof.challenge.timestamp)) {
handleError(
'Timestamp hors fenêtre de validité (anti-rejeu)',
@ -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,10 +638,20 @@ export function LoginScreen(): JSX.Element {
<th>Clé publique</th>
<th>Cardinalité min.</th>
<th>Dépendances</th>
<th>Statut</th>
</tr>
</thead>
<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}>
<td>{req.membre_uuid.slice(0, 8)}...</td>
<td>
@ -628,8 +675,12 @@ export function LoginScreen(): JSX.Element {
? req.dependances.join(', ')
: '—'}
</td>
<td>
{status === 'reçue' ? '✓ Reçue' : '✗ Manquante'}
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@ -673,6 +724,15 @@ export function LoginScreen(): JSX.Element {
<button type="button" onClick={handleAddPair}>
Ajouter un pair
</button>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_OPEN_DIAGNOSTIC' });
navigate('/diagnostic');
}}
>
Diagnostic avancé
</button>
<button
type="button"
onClick={(): void => {
@ -686,6 +746,39 @@ export function LoginScreen(): JSX.Element {
</section>
)}
{loginState === 'S_ERROR_FATAL' && (
<section aria-labelledby="error-fatal">
<h2 id="error-fatal">Erreur fatale</h2>
<p>
Une erreur bloquante s&apos;est produite. Impossible de continuer sans
intervention externe.
</p>
{error !== null && (
<div>
<p>
<strong>Erreur:</strong> {error.message}
</p>
{error.code !== undefined && (
<p>
<strong>Code:</strong> {error.code}
</p>
)}
</div>
)}
<div>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_EXIT' });
navigate('/');
}}
>
Quitter
</button>
</div>
</section>
)}
{showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (
<section aria-label="Reprise">
<button type="button" onClick={handleSyncNow}>
@ -734,6 +827,7 @@ export function LoginScreen(): JSX.Element {
</p>
</div>
{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
<div>
<button
type="button"
onClick={handlePublish}
@ -745,6 +839,30 @@ export function LoginScreen(): JSX.Element {
? 'Publication…'
: 'Publier la preuve'}
</button>
{publishRelayStatus.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<h4>Statut par relais</h4>
<table>
<thead>
<tr>
<th>Relais</th>
<th>Statut</th>
<th>Erreur</th>
</tr>
</thead>
<tbody>
{publishRelayStatus.map((status, idx) => (
<tr key={idx}>
<td>{status.endpoint}</td>
<td>{status.ok ? '✓ OK' : '✗ Échec'}</td>
<td>{status.error ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{isCollecting && proof !== null && (
<LoginCollectShare
@ -900,8 +1018,14 @@ export function LoginScreen(): JSX.Element {
>
Réessayer
</button>
<button type="button" onClick={handleSyncNow}>
Synchroniser
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_RESYNC' });
navigate('/sync');
}}
>
Resync
</button>
<button
type="button"

View 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>
);
}

View File

@ -11,12 +11,23 @@ import { useIdentity } from './useIdentity';
import { signMessage, generateChallenge } from '../utils/crypto';
import { GraphResolver } from '../services/graphResolver';
import { updateGraphFromMessage } from '../services/syncUpdateGraph';
import {
loadDefaultContract,
hasDefaultContract,
} from '../utils/defaultContract';
import type { LoginProof } from '../types/identity';
import type { Contrat, Action } from '../types/contract';
export function useChannel() {
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(
(_message: AuthRequestMessage): void => {
@ -52,11 +63,25 @@ export function useChannel() {
const handleContract = useCallback(
(message: ContractMessage): void => {
const payload = message.payload;
let hasValidContract = false;
if (payload?.contrat !== undefined) {
// Valider et ajouter le contrat principal
try {
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);
hasValidContract = true;
} else {
console.warn(
'Invalid contract structure received via channel: missing required fields',
);
}
} catch (err) {
console.error('Error processing contract from channel:', err);
}
@ -67,7 +92,16 @@ export function useChannel() {
for (const cf of payload.contrats_fils) {
try {
const contrat = cf as Contrat;
if (
typeof contrat.uuid === 'string' &&
typeof contrat.version === 'string'
) {
updateGraphFromMessage(contrat, graphResolver);
} else {
console.warn(
'Invalid child contract structure received via channel',
);
}
} catch (err) {
console.error('Error processing child contract from channel:', err);
}
@ -79,12 +113,26 @@ export function useChannel() {
for (const a of payload.actions) {
try {
const action = a as Action;
if (
typeof action.uuid === 'string' &&
typeof action.validateurs_action === 'object' &&
action.validateurs_action !== null
) {
updateGraphFromMessage(action, graphResolver);
} else {
console.warn('Invalid action structure received via channel');
}
} catch (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],
);

View File

@ -15,7 +15,9 @@ export type LoginState =
| 'S_LOGIN_VERIFY_LOCAL'
| 'S_LOGIN_SUCCESS'
| 'S_LOGIN_FAILURE'
| 'S_ERROR_RECOVERABLE';
| 'S_ERROR_RECOVERABLE'
| 'S_ERROR_FATAL'
| 'S_DIAGNOSTIC';
export type LoginEvent =
| { type: 'E_SELECT_SERVICE'; serviceUuid: string }
@ -35,7 +37,10 @@ export type LoginEvent =
| { type: 'E_DONE' }
| { type: 'E_RETRY' }
| { 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 {
nextState: LoginState;
@ -159,6 +164,15 @@ export function transition(
if (event.type === 'E_DONE') {
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;
case 'S_ERROR_RECOVERABLE':
@ -171,10 +185,26 @@ export function transition(
if (event.type === 'E_ADD_PAIR') {
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
}
if (event.type === 'E_OPEN_DIAGNOSTIC') {
return { nextState: 'S_DIAGNOSTIC' };
}
if (event.type === 'E_BACK') {
return { nextState: 'S_LOGIN_SELECT_SERVICE' };
}
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 };
@ -189,5 +219,9 @@ export function isTerminal(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';
}

View 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);
}
}

View 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);
}

View File

@ -3,22 +3,37 @@ import type { RelayConfig } from '../types/identity';
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
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).
* When msgCle is provided (e.g. login ECDH), posts it for scan fetch decrypt flow.
* Returns detailed status per relay.
*/
export async function publishMessageAndSigs(
relays: RelayConfig[],
msgChiffre: MsgChiffre,
ourSigs: ProofSignature[],
msgCle?: MsgCle | null,
): Promise<number> {
let ok = 0;
): Promise<PublishResult> {
const relayStatus: RelayPublishStatus[] = [];
let successCount = 0;
for (const r of relays) {
if (!r.enabled) {
continue;
}
try {
// Ordre strict : message → signatures → clés
await postMessageChiffre(r.endpoint, msgChiffre);
if (msgCle !== undefined && msgCle !== null) {
await postKey(r.endpoint, msgCle);
@ -35,10 +50,19 @@ export async function publishMessageAndSigs(
};
await postSignature(r.endpoint, msgSig);
}
ok++;
relayStatus.push({ endpoint: r.endpoint, ok: true });
successCount++;
} catch (err) {
const errorMsg =
err instanceof Error ? err.message : String(err);
console.error(`Publish to ${r.endpoint} failed:`, err);
relayStatus.push({
endpoint: r.endpoint,
ok: false,
error: errorMsg,
});
}
}
return ok;
return { successCount, relayStatus };
}

View File

@ -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.
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
- `index.html` : page avec iframe, zone de statut, bouton auth.