diff --git a/features/userwallet-champs-obligatoires-cnil.md b/features/userwallet-champs-obligatoires-cnil.md new file mode 100644 index 0000000..a63b240 --- /dev/null +++ b/features/userwallet-champs-obligatoires-cnil.md @@ -0,0 +1,27 @@ +# UserWallet – Champs obligatoires et attributs CNIL (complément specs) + +**Author:** Équipe 4NK +**Date:** 2026-01-26 + +## Objectif + +Documenter le complément de spécifications : champs (Champ) obligatoires des contrats et attributs CNIL dans `datajson`. + +## Référence + +`userwallet/docs/specs-champs-obligatoires-cnil.md` — document détaillé. + +## Synthèse + +- **Champs obligatoires** : tous les contrats ont certains des 9 types (partage institutions, RSSI, Correspondant CNIL, Responsable cybersécurité, support infogérant / admin / N1 / N2 / N3). Identification via `types` ou `datajson`. +- **Attributs CNIL dans datajson** : `raisons_usage_tiers`, `raisons_partage_tiers` (tableaux [raisons, tiers]), `conditions_conservation` (au moins `delai_expiration`). + +## Modifications code + +- **`types/message.ts`** : `RaisonsTiers`, `ConditionsConservation` ; `DataJson` étendu avec `raisons_usage_tiers?`, `raisons_partage_tiers?`, `conditions_conservation?`. +- **`docs/synthese.md`** : référence au complément specs. + +## Modalités d’analyse + +- Vérifier que les objets Champ / Contrat chargés peuvent porter les nouveaux champs CNIL sans erreur. +- Validation stricte CNIL (présence des champs lorsque requis) non implémentée ; à prévoir selon politique métier. diff --git a/features/userwallet-collecte-distante-2-devices.md b/features/userwallet-collecte-distante-2-devices.md new file mode 100644 index 0000000..c3a084f --- /dev/null +++ b/features/userwallet-collecte-distante-2-devices.md @@ -0,0 +1,40 @@ +# UserWallet – Collecte distante (2 devices) + +**Author:** Équipe 4NK +**Date:** 2026-01-26 + +## Objectif + +Supporter `cardinalite_minimale > 1` avec deux appareils : signature locale sur le 1ᵉʳ, signature sur le 2ᵉ, récupération des sigs via les relais, puis publication de la preuve lorsque toutes les sigs requises sont réunies. + +## Flux + +1. **Device 1** : construire le challenge, signer localement (pairs locaux du membre), publier message + sigs locales sur les relais. +2. **Device 1** : boucle de collecte — fetch des sigs par hash sur les relais, merge avec les sigs locales, dédup par pair. Dès que `hasEnoughSignatures` (assez de pairs distincts par membre par rapport à `cardinalite_minimale`), on arrête. +3. **Device 2** : obtenir hash et nonce (lien ou QR émis par Device 1 pendant la collecte). Ouvrir `/login-sign?hash=...&nonce=...`, signer `hash-nonce` avec la clé du pair local, poster la signature sur les relais. +4. **Device 1** : une fois assez de sigs (dont celle du 2ᵉ), vérification (dépendances, clés autorisées, strict), marquage du nonce, envoi de la preuve au parent (iframe). + +## Modifications + +- **`loginBuilder.signChallenge`** : signature de `hash-nonce` uniquement (plus `pairUuid`), pour alignement vérif + relais. +- **`loginValidation`** : `requiredSigsPerMember`, `hasEnoughSignatures` (pairs distincts par membre). Suppression du refus systématique `cardinalite > 1`. +- **`collectSignatures`** : `fetchSignaturesForHash`, `buildPairToMembers`, `buildPubkeyToPair`, `mapMsgSignaturesToProofFormat`, `runCollectLoop` (poll + timeout). +- **`loginPublish`** : `publishMessageAndSigs` (message + sigs locales vers relais). +- **`LoginScreen`** : signature pour les **pairs locaux** du membre uniquement ; après publication, boucle de collecte si `loginPath` ; vérification et envoi de la preuve après collecte. Affichage « En attente des signatures des autres appareils… » + `LoginCollectShare` (lien + QR vers `/login-sign`). +- **`LoginSignScreen`** : route `/login-sign?hash=...&nonce=...` ; signature et publication de la sig sur les relais. +- **`LoginCollectShare`** : lien et QR vers `/login-sign?hash=...&nonce=...` pendant la collecte. + +## Modalités de déploiement + +Déploiement classique du front userwallet. Aucune évolution côte relais. + +## Modalités d’analyse + +- **Device 1** : construction du chemin, challenge, publication → collecte → affichage du lien/QR. Ouvrir le lien sur le 2ᵉ appareil, signer → retour sur le 1ᵉʳ, la collecte doit finir et la preuve être envoyée. +- **Device 2** : aller sur `/login-sign?hash=...&nonce=...` (ou scanner le QR), vérifier « Signature publiée ». +- Vérifier timeout de collecte (5 min) si le 2ᵉ ne signe pas. + +## Références + +- `features/userwallet-validation-conformite.md` (§ Cardinalite_minimale) +- `userwallet/docs/specs.md` (collecte signatures mFA, S_LOGIN_COLLECT_SIGNATURES) diff --git a/features/userwallet-contrat-login-reste-a-faire.md b/features/userwallet-contrat-login-reste-a-faire.md index c0220a2..a8367fc 100644 --- a/features/userwallet-contrat-login-reste-a-faire.md +++ b/features/userwallet-contrat-login-reste-a-faire.md @@ -33,8 +33,8 @@ Référence : `userwallet/docs/specs.md` (modèle, objets, machine à états, ca ### 3.1 Machine à états formelle -- **Fait** : `loginStateMachine` (états S_LOGIN_*, événements E_*), `useLoginStateMachine`, dispatch dans LoginScreen, Retour → E_BACK. Voir `features/userwallet-login-state-machine.md`. -- **À prévoir** : timeouts (réseau, collecte signatures), reprise sur erreur, backoff ; gardes explicites (G_PAIRING_SATISFIED, etc.) côté UI. +- **Fait** : `loginStateMachine` (états S_LOGIN_*, événements E_*), `useLoginStateMachine`, dispatch dans LoginScreen, Retour → E_BACK ; G_PAIRING_SATISFIED, E_ADD_PAIR / E_SYNC_NOW. **Timeouts** : `RELAY_FETCH_TIMEOUT_MS` sur tous les fetch relay (GET/POST). Pas de backoff. Voir `features/userwallet-login-state-machine.md`, `features/userwallet-timeouts-backoff.md`. +- **À prévoir** : timeouts sur collecte signatures (fetch relay) si distinct. ### 3.2 Écrans et UX alignés specs @@ -65,8 +65,8 @@ Référence : `userwallet/docs/specs.md` (modèle, objets, machine à états, ca ### 3.5 Validation et conformité -- **Fait** : `verifyMessageSignaturesStrict`, `filterSignaturesByAuthorizedPubkeys` ; avant publish login, si `cle_publique` dans requirements → vérif stricte, sinon X_PUBKEY_NOT_AUTHORIZED. Contrats en version non supportée exclus du graphe (sync) ; `contrat_version` dans LoginPath et affichage. Voir `features/userwallet-validation-conformite.md`. -- **À renforcer** : cardinalité, dépendances entre signatures ; résolution `cle_publique` depuis graphe quand absente des requirements. +- **Fait** : `verifyMessageSignaturesStrict`, `filterSignaturesByAuthorizedPubkeys` ; `buildAllowedPubkeys` (résolution `cle_publique` depuis pairs) ; `checkCardinalitySupported` (refus si `cardinalite_minimale > 1`) ; **`checkDependenciesSatisfied`** (membres des `dependances` doivent avoir ≥1 signature dans la preuve, via pair du membre) ; contrats version non supportée exclus ; `contrat_version` affiché. Voir `features/userwallet-validation-conformite.md`. +- **Fait** : support de `cardinalite_minimale > 1` via **collecte distante** (2 devices). Device 1 publie message + sigs locales, boucle de collecte (fetch sigs par hash), Device 2 signe via `/login-sign?hash=...&nonce=...` (lien/QR) et poste sa sig. Voir `features/userwallet-collecte-distante-2-devices.md`. ### 3.6 Scan et optimisation (optionnel) diff --git a/features/userwallet-login-state-machine.md b/features/userwallet-login-state-machine.md index 6ef1289..1f30159 100644 --- a/features/userwallet-login-state-machine.md +++ b/features/userwallet-login-state-machine.md @@ -25,11 +25,16 @@ Implémenter la machine à états formelle du login (S_LOGIN_* , E_* , transitio S_LOGIN_SELECT_SERVICE, S_LOGIN_SELECT_MEMBER, S_LOGIN_BUILD_PATH, S_LOGIN_CHECK_PAIRS, S_LOGIN_NEED_MORE_PAIRS, S_LOGIN_BUILD_CHALLENGE, S_LOGIN_COLLECT_SIGNATURES, S_LOGIN_PUBLISH_PROOF, S_LOGIN_VERIFY_LOCAL, S_LOGIN_SUCCESS, S_LOGIN_FAILURE, S_ERROR_RECOVERABLE. +## Compléments (après 3.1) + +- **G_PAIRING_SATISFIED** : LoginScreen vérifie `isPairingSatisfied()` ; si faux, affiche « Pairing obligatoire » + « Configurer le pairing » → /manage-pairs, « Retour » → /. +- **E_ADD_PAIR / E_SYNC_NOW** : quand chemin incomplet ou S_ERROR_RECOVERABLE, boutons « Synchroniser » (→ /sync) et « Ajouter un pair » (→ /manage-pairs) ; dispatch avant navigation. E_ADD_PAIR depuis S_ERROR_RECOVERABLE → S_LOGIN_SELECT_SERVICE. +- **Timeouts** : `RELAY_FETCH_TIMEOUT_MS` sur tous les fetch relay. Pas de backoff. Voir `features/userwallet-timeouts-backoff.md`. + ## Reste à faire (hors scope 3.1) - Timeouts (réseau, collecte signatures), backoff. -- Gardes explicites (G_PAIRING_SATISFIED, etc.) côté UI avant dispatch. -- S_LOGIN_NEED_MORE_PAIRS : boutons E_ADD_PAIR, E_SYNC_NOW dédiés. +- S_LOGIN_NEED_MORE_PAIRS : écran dédié (actuellement chemin incomplet + recovery). ## Modalités de déploiement diff --git a/features/userwallet-timeouts-backoff.md b/features/userwallet-timeouts-backoff.md new file mode 100644 index 0000000..b784e74 --- /dev/null +++ b/features/userwallet-timeouts-backoff.md @@ -0,0 +1,30 @@ +# UserWallet – Timeouts réseau (3.1) + +**Author:** Équipe 4NK +**Date:** 2026-01-26 + +## Objectif + +Timeouts sur les appels relay (X_RELAY_TIMEOUT). Pas de backoff. + +## Impacts + +- **Relay** : tous les fetch (GET /messages, /signatures, /keys, /bloom ; POST /messages, /signatures, /keys) utilisent `AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS)` (15 s). En cas de dépassement, l’appel échoue (AbortError). + +## Modifications + +- **`utils/relay.ts`** : `RELAY_FETCH_TIMEOUT_MS` ; `signal: AbortSignal.timeout(...)` sur chaque `fetch`. +- **`services/syncService.ts`** : pas de retry ; en échec fetch, le relais est marqué `ok: false`. + +## Modalités de déploiement + +Déploiement classique du front userwallet. + +## Modalités d’analyse + +- Relais lent ou coupé : timeout après 15 s ; `relayStatus` indique `ok: false` pour ce relais. Log « Error syncing from … ». + +## Références + +- `features/userwallet-contrat-login-reste-a-faire.md` (§ 3.1) +- `userwallet/docs/specs.md` (X_RELAY_TIMEOUT) diff --git a/features/userwallet-validation-conformite.md b/features/userwallet-validation-conformite.md index 15c3c96..7d87f9a 100644 --- a/features/userwallet-validation-conformite.md +++ b/features/userwallet-validation-conformite.md @@ -17,7 +17,8 @@ Renforcer la validation : validateurs stricts, clé autorisée, version des cont **Vérification stricte** - `utils/verification.ts` : `filterSignaturesByAuthorizedPubkeys`, `verifyMessageSignaturesStrict`. Garde `verifyMessageSignatures` (crypto seule) pour usage générique. -- `LoginScreen` : avant publish, si `loginPath.signatures_requises` comporte des `cle_publique`, construction de `allowedPubkeys` ; appel à `verifyMessageSignaturesStrict` sur la preuve ; si aucune signature autorisée ou présence de non autorisées → erreur X_PUBKEY_NOT_AUTHORIZED, pas de publication. +- `utils/loginValidation.ts` : `buildAllowedPubkeys` (depuis requirements + pairs ; clé locale pour pair local), `checkCardinalitySupported` (refus si `cardinalite_minimale > 1`), **`checkDependenciesSatisfied`** (membres des `dependances` doivent avoir ≥1 sig dans la preuve via pair du membre). +- `LoginScreen` : avant publish, `checkCardinalitySupported`, **`checkDependenciesSatisfied`** (sinon DEPENDENCIES_UNSATISFIED) ; `buildAllowedPubkeys` ; `verifyMessageSignaturesStrict` ; si sig non autorisée → X_PUBKEY_NOT_AUTHORIZED. **Version contrats** @@ -34,7 +35,27 @@ Déploiement classique du front userwallet. - Contrat en base avec `version` non supportée → sync : log « Contrat … version … not supported, skipped », absent du graphe. - Login path résolu avec contrat → « Version contrat » affiché. -- Preuve avec signature dont `cle_publique` hors validateurs (si `cle_publique` dans requirements) → X_PUBKEY_NOT_AUTHORIZED avant publish. +- Preuve avec signature dont `cle_publique` hors validateurs (requirements ou résolution pairs) → X_PUBKEY_NOT_AUTHORIZED avant publish. +- Requirement avec `cardinalite_minimale > 1` → CARDINALITY_UNSUPPORTED avant publish. +- `dependances` non satisfaites (membre requis sans signature) → DEPENDENCIES_UNSATISFIED avant publish. + +## Cardinalite_minimale — explication + +**Ce que ça veut dire** + +Dans les validateurs, un requirement peut avoir `cardinalite_minimale` (ex. 1, 2, …). Cela impose **au moins N signatures** pour ce requirement, en général venant de **N pairs distincts** du même membre (mFA). Ex. : « il faut 2 signatures du membre X » → 2 pairs (2 devices) doivent signer. + +**Pourquoi c’est refusé aujourd’hui** + +Le flux login ne produit **qu’une seule signature par requirement** (un pair par requirement). Si `cardinalite_minimale > 1`, on exigerait plusieurs sigs pour le même requirement, ce qui n’est pas implémenté. D’où le refus : avant publish, `checkCardinalitySupported` vérifie que tout requirement a `cardinalite_minimale` ≤ 1 ; sinon → CARDINALITY_UNSUPPORTED. + +**Implémenté (collecte distante 2 devices)** + +- Device 1 signe localement, publie message + sigs, puis **boucle de collecte** (fetch sigs par hash sur les relais) jusqu’à avoir assez de pairs distincts par membre (`hasEnoughSignatures`). +- Device 2 ouvre `/login-sign?hash=...&nonce=...` (lien ou QR affiché pendant la collecte), signe `hash-nonce` avec son pair local, poste sa signature sur les relais. +- Device 1 récupère ainsi les sigs du 2ᵉ appareil, finalise la preuve, vérifie (dépendances, clés autorisées), marque le nonce, envoie la preuve au parent. + +Voir `features/userwallet-collecte-distante-2-devices.md`. ## Références diff --git a/hash_list.txt b/hash_list.txt index 268ecfe..91b75bb 100644 --- a/hash_list.txt +++ b/hash_list.txt @@ -22187,4 +22187,231 @@ e00b3bb7b9abd55bbd3112b5bc641008e335b12ae0624548221c047291aa2e68;2b1898464a91291 9b57cae546364d361deda871938970af07596e0ac454bb687e8e31b4850d7cd7;a26f7ba2a7e34a1d31fb903ed41a9296f5598fb7e571ef0975caf0b9be3183d9;10051;1;2026-01-26T12:59:22.505Z 06121bc2751e64748e536cf49484043bf5ef87fb027922063c8a5a984fc6284b;fbe7563f8099ebbde05178514a4497397a9ace6f23911c99f6a626757ce6f7db;10051;1;2026-01-26T12:59:22.507Z 3e4726a5ed732d0abbdb1069e42c6f13d34ea16ebd8353e2d24720a866424cc2;1ce6d0179d049585c37e1e756173b619131055f4c7b41d3d08735c2ea540f6fa;10051;1;2026-01-26T12:59:22.510Z -6100ed0ee59a17a23292979ef05d6baecaf6686b7a8045385a05b57c8035a684;e357d1504e963d89982fabeb0d63e10a3305b515fbe22f5a2b87a88abd846afe;10051;1;2026-01-26T12:59:22.512Z \ No newline at end of file +6100ed0ee59a17a23292979ef05d6baecaf6686b7a8045385a05b57c8035a684;e357d1504e963d89982fabeb0d63e10a3305b515fbe22f5a2b87a88abd846afe;10051;1;2026-01-26T12:59:22.512Z +7215b51e9d2d3502b5bfde1bf88de26997676dfdd3e27494582e173e481b44ff;c44ca4a43ccce1ea81e2debe80d775b07222cdb206c0939f79767db2853da808;10052;1;2026-01-26T13:00:22.495Z +7b9f00a136d9f12e6867a14bff2fb50d939f72917c42467a20329b75355a5d02;18b4ce8cb6631dcf539718dc1a3105086cdb135d62f889e593576628444adb0a;10052;1;2026-01-26T13:00:22.497Z +bfa0f7a88fb8a4f9249e909b51cc9e1240d9c01a0ffa32af34f87f708b8ac9a2;159bf97b811c7a0fdf2ed719669198c2248bfe01e833c562dbb5bd03d6f7181a;10052;1;2026-01-26T13:00:22.499Z +80df91e877bf5e71ded87f6dded79cff11bb4473868fcdce3024f4c8f29c8a7e;d17046aefc28f037294abcb50d44e7b8b6dcc6e837b3cf7a104d87a90f3f104e;10052;1;2026-01-26T13:00:22.501Z +93dc5962fd59708b174d5c3c0bd5cff25a13449f9704a487d5253e50ee911d08;8f909665985168ad5db0546ee156c500adc9572d7fa6cbc0dc3c6c5235a91d56;10052;1;2026-01-26T13:00:22.504Z +aaf3edf8e02e402043a131ba4e9875b6fe621d5432b3b87cce02621b0fdc0f74;4f56e44253be7b7c172c2a14bd682d5d64fe2fed44090f0135e732e3f9af736d;10052;1;2026-01-26T13:00:22.506Z +73724934bd3b256b1f30049fc35e2f375a9259f599c9db74cbd8baf5b1187123;c84c058ef2665ebc56f62603d7d1fcbee29da986d660d09f6b66f2ca380fa282;10052;1;2026-01-26T13:00:22.508Z +d6950e04f03f4b26d8d96f3dcd6b1397823ea85a33b594ecd2353659cf39f59e;402b53b87f6d8cef21103b496675766ec0d4382f750df5a57e7caabf9741db8d;10052;1;2026-01-26T13:00:22.510Z +b0a91e9e7110fbf9955f06a019932e7f9392f06b2b79105ae7693ada8b639997;a6e7710fdd0a792ebb791813e3ea152f4685360ead303201cd2479dc4bde60a6;10052;1;2026-01-26T13:00:22.512Z +963e436f8d8796d04e6b6aaf7fa6251d06680bbb2894c7aa1ddf7359f8acb022;455941b31a47ad293d78b9d6c0f445f37f82cb33daec9b6e98328bb5e774d8b5;10052;1;2026-01-26T13:00:22.514Z +677f6f4466f2331310557296350891fda7bad4fcecbbad0dc45ad8b3c8cfb989;275f758f27daf8e801274ae5151bf6367ea2a92f27ba0b04ab1aab29e0b34abf;10052;1;2026-01-26T13:00:22.516Z +52b3e52857e93b78ef86cf0626bd02ba99755dff4cd24f8f2b522b0226e14a35;0ba71e4ef93d24ace847c1fc7414508fa9c9e4e0192c6dd0539cfd1ae645b3cd;10052;1;2026-01-26T13:00:22.519Z +bd112b689e67f55bc335d4724c8d54da5bd4fcfccdf93661e71cbe8c212dbaf4;2adde006795a2268d9c227dee6b3af598133ca9317681a8b73d69530f34159e5;10052;1;2026-01-26T13:00:22.522Z +bb6115963e6a5f4721be55d86f9ac8ec4f651714e707d3cac027ebb8cc1c9a54;aa98c273772d474290a881a8ebdb59dcf59f5e7bf5a146a2eff67c8912ef1a19;10053;1;2026-01-26T13:01:22.412Z +8dcc247a690f8eebae1b35942ca96d48d355763bfa9e7a2f431689a4f0348b37;fbfb59f9867fe87748c89f4877ca1a1b4512fb23559af287104fe761cfac2723;10053;1;2026-01-26T13:01:22.414Z +de0b5e6b582a8d6d8f64edd1603bde002572daa7d68b0249133137f2bebbe7a6;6cfd101f77965ac7ae9ba855fd16772258a9c6ce311a1c4ce18f7961e8e0b129;10053;1;2026-01-26T13:01:22.417Z +fd6080715c36a907ddc00ead7a8675bfba90ea61b9bd0153dde2460144127323;b59edd2421af262783767b680d1ae5fc209f9f9b0e62ef0dbfeca2e0b578b13b;10053;1;2026-01-26T13:01:22.419Z +1212a86ec51b1dced37ea4baeccbec9ecd79961797e60cc65e503eef64be076f;a494f7266f41e044b6f2c55061e86039771c160965f0196e4ff8c12cd647cb45;10053;1;2026-01-26T13:01:22.421Z +0f6cd1ab9bd22e2a0f1fae03875f5d821d4c151d1c400ed5bb4dee43d01bc7eb;0c0f16cc44fd4e59cdf027dccae3149239d4efbbd81f995d94b6219f3924284e;10053;1;2026-01-26T13:01:22.423Z +8863b0fb3336d1f19f3fb5447322ddd230f3ab6a186b6c2ca67d9c40dd0c3187;122c2a3c8b362cf79ea51376c3fcf9a45ca07eaffa5da3ff9b0963a94bb5da52;10053;1;2026-01-26T13:01:22.425Z +86695473a28f73d5a56267804b422027028965a078d8112e5abe04f8b2b452a1;5087e410f6d592d92e7a0cc677fa616f2d170b5a9062e316b2455416c4cdaf9d;10053;1;2026-01-26T13:01:22.427Z +e3d81382b96c56c97a9582588b66b813a07eb696af18d015c92a613f91effff2;fe7edcca7eba95fbef9a4975eac73f92fef181bcaefdff4c4a81b9a56eb6bc9d;10053;1;2026-01-26T13:01:22.429Z +903bf2fc232d7ef6fb7cf405b7f185b4082e7e432649cac139bdeca8dd5b727b;ec412710f42bb3667b3f6452d17dbf06d909a2ba38230d1fd23f331bd96ef0a1;10053;1;2026-01-26T13:01:22.431Z +39b045ee96ae0ef583233fb230013fa093f5f210bc8b17a82d1be0e9d434ce43;1abfe59bcd1b920170e32035ffdd92a691aaa6255591451568b4493418671ca5;10053;1;2026-01-26T13:01:22.433Z +37171bab7903389055c74efab25c8a9758ce436571a9ac8ae38a777455465432;2b3659ae0706da43c49ec152aaca853e001265e41fe27619add259cfc33567b3;10053;1;2026-01-26T13:01:22.436Z +d0c6750242dd3613660e2461b4e8d96a0673d6c546fd04ca4331a382162a5089;dcb43a098bcf261702f156f754f8ea03eadbb7514363d02d1a9efd870a3097bb;10053;1;2026-01-26T13:01:22.438Z +9d8a0aa2d860bf18a18eafed687f58c530f3c128f41f4bfaf6a85b0d0b57c2f9;8d0d6401963fa69a561ccd593ccf219d8a9268456e92cb927ee49559abdf2bc1;10053;1;2026-01-26T13:01:22.440Z +3ff0529894a263b3de141e85e03528341e5cbc70e9f566327771343c7c662a5f;3ed20330c0fcb16030da97edf89bee378b20c939a2a3da0bba49242ece2c80cf;10053;1;2026-01-26T13:01:22.442Z +ccde40c98482d2274a4bddf01dddd9278a8a8b398a11e0f5b2784f6933a618f7;4e73063e89687225398709ffa01d87fa31e1decff92f90f79980a66a1e039eda;10053;1;2026-01-26T13:01:22.444Z +93dc5962fd59708b174d5c3c0bd5cff25a13449f9704a487d5253e50ee911d08;589bd3e0e58c4ea13ced809fbeb7cc1f85dd63ed054bfd21195a7ba6aa403ced;10053;1;2026-01-26T13:01:22.447Z +f33e7441b2a201056b062d83a0aa65a8d78fdefd2b8a2abea9db09c8c878b867;b7868d8612a838ed833b19cc9c2487cb63a3d432dc76fa934f0c33e7fb0806f2;10053;1;2026-01-26T13:01:22.449Z +265d0fce64dcf56d11d3d6d2202eaeee2e2c4e29e1b8d2ed850e7a56bd24d75c;aecb71828e73c1ed7fed2ccf91d2c05d502915bda64f404b3057cbd54a30301c;10054;2;2026-01-26T13:02:22.471Z +56ce0ccbfbda935081afbdedeb2c2a6f3b1fc50f6c5011e88676b3f35d50850d;e985291676c45ce779cc20806a524613b944326d3b62bb8ab2ce8302bc266d39;10054;2;2026-01-26T13:02:22.474Z +741abef5adde8f13b93291f9becf2c6e86062cd8a09784ac674098162dca527b;0e110eb25eb706f8d3433c0edbc788bf9d022e0aa1b78ef37038e7af17225048;10054;2;2026-01-26T13:02:22.477Z +e61888fa5009a58fddd20f3c7347ef05230b25cfcbe6521f67e7d83f8ef3edd3;2cc6600b3b3ca3e96f5305707dcc75ced00f9b6333558e66e4a0059114ae9f61;10054;2;2026-01-26T13:02:22.479Z +0b70e0de2ca4ef6b964a35601ea7d15e9e24179635fe1653e6bc4778f686eac5;f64317e9d09062ec1b0f490da940cd98a78e24eb1889aa9b14cc14e1c216326d;10054;2;2026-01-26T13:02:22.482Z +4f026eae94d5c2be2cf35cbe08a28bbadb024b6e13fec1f9608157fded569d5d;f68e651fec8dc3bc1978ddb8343db3178a4130d6f40861f415a561e74c57957d;10054;2;2026-01-26T13:02:22.486Z +6d94e5ca5b12d95a87c8ceb8a91fa7b1a97a6e1867b7e9d23cb7af2ff7d2830e;19b55abf27c3aaadc16882ca17773d1feb86e2ce77921d965822eae69fb2ad8a;10054;2;2026-01-26T13:02:22.489Z +4248f20d858166d13ad5b7d5a6114389ba8f9043ed3eb3f49c992cdbed825f8d;167313176a74841ca3e6ad8b9307892c56e2f92f25255db1eb5bc7a47696aa98;10054;2;2026-01-26T13:02:22.491Z +a0593d88f7af23dad94310700c11b443c181634e2488637746ed35d188d75842;ad211c67f74acc56b49970411da29af0ca1b25bd8a2aeedb99e146f8beaf6aa3;10054;2;2026-01-26T13:02:22.494Z +ca3b804ba4b173db8bd540c8e8f430e2080895bce0a028dd9bafdd8fec3b0669;a69191780ce93ca5e308bc4e0eb4d51d94ec5e9f5832bfea40494076df8b0eb7;10054;2;2026-01-26T13:02:22.499Z +90e25718842a81307e476403582bd84c852929283e9f2437d1e61b0b1461e32b;5f14bc3bb5cd375555f3549e51aa83a76ca054eccc0548b6a5b77a6472fb32b9;10054;2;2026-01-26T13:02:22.501Z +3a2ae26dba4671b9b7be50dae7e78f36de8c36b50d3fd6d2f5031e3d9da6499f;2fa4aa6bc717647581337e2925a19f990020be3b2db2169511eb78c4fc83c1da;10054;2;2026-01-26T13:02:22.504Z +fe6767db6eb19164e5e86e80769e534b7fc6629933e2dadd7bbff5cec2d2c5c1;4e43e12d44d1304e9e3c07dd697b56048f7f493189a66326f3d341655c4fa8ee;10054;2;2026-01-26T13:02:22.506Z +4113afb63b631afb701a9d128e7d0f50825205988be8911932f3790463a4fd6b;bcbb24adf8716b8abcfce163fdb9db2ba21b3351abb016884f2b5ad8674023fe;10054;2;2026-01-26T13:02:22.508Z +1a0ea056ae30d69619c0d4f4a4a280244b60c7744313cc2d4034303116473452;071d3f4bf3c574b1cd66cb9acf4d17bae3305287481074bf2189693dac62c12c;10055;1;2026-01-26T13:02:22.518Z +61336880f92e51e3198af9434137b16e838e22d84163a234fa5785c3d05ac8d6;53dc474c56e7425b1e9b5b10f4949aeab1728683df1a07b0898dab4a03f22fd5;10055;1;2026-01-26T13:02:22.520Z +8637cc84ac732e688cbcb5a9f5be3755600c1b42526d89ba3bedd5c2ce30db9d;7cb5083d5dcf0cc640c7137e231ec42b449ffa10ab13425c113e6f0b99e67bf4;10055;1;2026-01-26T13:02:22.522Z +b2a098c9e0db2eeb042bdc5fa461fc17e6ee9c003f65d5205cc23e707355ac87;3eaf2444fdc60770fe0cfbb89013773d427c6b7f9391a1b60a5029b546b1c906;10056;1;2026-01-26T13:03:22.507Z +3045c345c5db27444a2e548b53ec45e369084bb74e242c53a78f647a70392d27;94dbf22a024a801e26fdca9f7e9de734503e86eb031dc34d80a908592c6d3656;10056;1;2026-01-26T13:03:22.511Z +28859a2376ddb9e3fbeea3a85cbd6e6ac4fe39b0594fc921e3d0145c96b145bd;e0593d0b6e8be5c12d4ee07bc953a12c7e4a492729c33d4b8d62465eeddeb57e;10056;1;2026-01-26T13:03:22.514Z +1f5207fb9603f2a3a880b55aef42939f6fffc741e004cd2c03bb3dd64a9eab39;a57734fc37e69f60cc13fb4557167c0b60ff6d9ca80a7d432d35acff24d9d8bd;10056;1;2026-01-26T13:03:22.519Z +0d3a5baa7ea0b8cb007683dd603546e406f7eebc38a3259b9228d9c0ca334326;fd7e6d5092c93046ebc499f79e892cd07c34ddb032e8a4b94d66e765daafe514;10057;2;2026-01-26T13:04:22.502Z +85657a9d34b0e7e73066333af46e29fe8f3aeca34de0a342d682909e3866db84;2de2b753c35c58ae5f673ae20110bf4214370999491e02f5ab3f47f55fa6b816;10057;2;2026-01-26T13:04:22.508Z +d700774c525705c05427415483910757eeb16dbd023fad758316fb3f222e8ade;3b06d0c188b02b250743db8f1934d009f677f479db573c9309d7832fe93a4b1a;10057;2;2026-01-26T13:04:22.512Z +4b46079e3f018c2472e576770fc2a159b3aad74fd5933a9f72f4326729241259;3535e70469759c71c81e8fe7a4c213961fed80af0b17063806710d35977a704f;10057;2;2026-01-26T13:04:22.515Z +5d00c6e49d1e39063126cb5d6cee77fa0e7a2effa9db227cc02f7fdf7a43ff63;e0a62afb9d180c2b59d07071d2262712cbda365a56639bd5115daf22c83cec4f;10057;2;2026-01-26T13:04:22.519Z +977a17eda01fd7be564f2c89535aaf26012db19878ea136fd005f1be3a655ae6;4ce53c63a4c95a9534eb4f1f9bc5a4b64e4b498ad0b9cd71787854e8b867c766;10057;2;2026-01-26T13:04:22.523Z +7bcd13301152297de3cfd11190575a5b87c79db9f493a1940e61c0d67fc84fb1;cca42a6354a170b782e5b9acdc7fde1f49370799f584bac281aeeb74ebf9636c;10057;2;2026-01-26T13:04:22.527Z +7f6e2eb9d03f4632702bb1d50629dcf6bbec800f63a9c753969befcacaca21c7;21802268d69750c1946e739f1dd5d7db928c2a19d1bcb73ddaa27e0c3aa76a8c;10057;2;2026-01-26T13:04:22.530Z +e2bc5b3cfcba8fc05d58e5a63afbeb1cf7c5b66a57171d10fc012809b43ee12f;dcf2a15afe410c700c7f43439c566aa0d4e71c8f6d32832cf591e34bba8d0da3;10057;2;2026-01-26T13:04:22.533Z +e122330f7cff47df7b4a568f1ef231ab85a31700f2fb2bb1f4f440d879ae048d;8ee8b853f0f86e61bf3b54b938c5e44a3660ac472d36b1defe67650b2b444cbc;10057;2;2026-01-26T13:04:22.535Z +cadee6e9cb47280d95979afb9298cdfbf420e438740247bf49a29d59d5248c96;b430a2f263e4f89d4a99bea8c3aa5719f6f8028b373aee92e1068f51d672adc0;10057;2;2026-01-26T13:04:22.537Z +d3210c8cb4b8d50f866620d4b6dea767d70be9c1772115b8f3c5548f5cf01c6e;abcac40f8da424988ea40df3e028e2cf754fe70f8d220ea1ec2ad1605abd96c6;10057;2;2026-01-26T13:04:22.539Z +9b2d6fa8c11104af4142702ef22204906f437609c58cb96fab34578d970d1e66;c0a7f914ee8ee6e39be84a1a0f53ea379c3ca2b6dd7bbad42f7bab56e03884c7;10057;2;2026-01-26T13:04:22.542Z +5e6f9e0fcb22f01c8e6b850070d1a1ddab737f1ada99a3bb5c4a29873f1d92d1;b7f2e6211860f48a3ca5f4ad9b919c55922d15beb5965e6461132c637bcb74cb;10057;2;2026-01-26T13:04:22.544Z +6930f4c38ff1982f25569c3ea2e22056408d101afe1cb67cfe6ce498a40feac9;6d376088b706bfdc1470fd56b4312654976e12e93bb318a92708bb5b117958d6;10057;2;2026-01-26T13:04:22.546Z +b524ac47961b1f6c60f3bf8995194baf1ee42e1a9da948580db3548a08b44e43;6767afb807c85861a085d0620a2c020fc79b88e708fd93ed36a1d174726ec4eb;10057;2;2026-01-26T13:04:22.548Z +b33eff7cc09675b6a6aaf683df76f4ecba172aebdb9321af60f90cc397fe563f;feda38f701bfadb2c2a6d63ef9b618b4ff2545ca8ddf022478ccd164c0ba3e29;10058;1;2026-01-26T13:04:22.557Z +b5b139f90722c320a7b1952aac832d4cb3d463292e3b9cba997f9bc0f509a9bc;269bcaf1fae4663845412f0ff6c7026e762fa8fff154f563b138578737164148;10058;1;2026-01-26T13:04:22.559Z +f5adeb7de44347cc7ea61e4809595477e7d782ecdd18a92ca31aab4fb264aabf;f9f42a887209ffb487921cd3140e8b6e7361afd1aa6b107bcc55e27844a8536a;10058;1;2026-01-26T13:04:22.561Z +69d7c66f8e7bcb1e0b9a678fcc5bbbe9ebeda914085c66847b794f2bd63fd05b;393bbee0aee50270b901b5de6cf5782f457415d33323536e6805810969e0bf6c;10058;1;2026-01-26T13:04:22.563Z +a3d00144b716eba229dcc467d86d5aadd2c1c0b7dd5e21dcd13a2bb52a201ef1;1c48efa8ed8fd905f903a5f501fbfe580ae95f6cf5b267e1620c02c9d1bdcc7f;10058;1;2026-01-26T13:04:22.565Z +a1a3d82edc02fd2b48a4149ad83a1febfa8a46e8bfb4c8e9f4d1c50ef3d2c20a;f4bb2b67d96a18b3d024781043f1e743f21240ae6c07b42ad8904095c96b3e8e;10058;1;2026-01-26T13:04:22.567Z +2550488656a3cbcc99368a54385a52deb03ea1ccbcb32389a9a5611b581a7c4d;8f909aa7085d2ef240470e951aabd9a8bacf47688bb82ffbab7d85d89a4e2bbb;10058;1;2026-01-26T13:04:22.569Z +7a6a78837abffee022bd294d8e7fb191aaecb5ab4442a3cdbcd43cba410c2f04;ba6af75be8d68430450f9be8d240295ce8630d0bbcfa1222e0d8a2f4ed3ecec6;10058;1;2026-01-26T13:04:22.571Z +eb3c8928e13441f45451f33a74e8478bf53f6d9d5d242aabbb9c2dfbbd18dde6;978406058058a5cb835eef74a535a54315042f3466f9b7cfc5b5232f6494bdf8;10058;1;2026-01-26T13:04:22.572Z +4b3b31182c85fd2e23ef2118551ea94ba2efd6c675ab40633019713db57789df;44022f80d4e6c4dc5ed4e614c9a2a7d72da4127a0e20d77d8c71cfd4b3bcac0a;10059;1;2026-01-26T13:05:22.489Z +5d6edccfab0ac3a6daa10e0f07166dd40c7fba923433b7c4c31e1022a8a02aa1;bd47d59858be6e7413580c2a635604f7331c9aca3a5116c27b483e5171cf0571;10059;1;2026-01-26T13:05:22.495Z +846b5a2507ad29635d48b8dcca342a62e5bb3af255e02216e35e6f9518c50076;d68451681716e9d74d3d46438d1ad26c3eefd302eee561e0ae8e5da7d6248ab0;10059;1;2026-01-26T13:05:22.497Z +5bdc308d0b738d1d23821964ce1a197750f3595c46ea368d87f9a96d966a060a;8e4c90fdffcfa81c8736275ee62af863c8a6fb43f7eb201c477bfffc5522d5b5;10059;1;2026-01-26T13:05:22.503Z +3c897fff69a7e23d63774bcad8f929c8a37836c05dfd04cea7f01bc4cf4616e0;baf6007c11ae40e4757ce432276333e82b83db5a39476cb150dc715d856b212a;10060;2;2026-01-26T13:06:22.484Z +bad75f719ec49fa53a63115f8626c197a4b3bdfa23e471e4c574faef16863c01;86113b24f6c91dcb718cd7c7e3915c7c7f1d7752c4ab233061bc9664e6e5a957;10060;2;2026-01-26T13:06:22.489Z +c51f00345179cb5699360f367f43fd93d1b4e756a9ccf4fbbb229050749ba03a;a5f5d806acfaf72c803cd74fc988e93a8a0efbaa78d6e5a5ce7876f087447db5;10060;2;2026-01-26T13:06:22.493Z +44ed50a63004a383eab4e76d323aa6e0b3d8629369d821a70de1ccc41ff55815;8f1b019b37a432ebddc8fbc9b87f6b66dd15f5d2e4db8ee7878df958c77835b6;10060;2;2026-01-26T13:06:22.497Z +cf89863d3d440eb248a5170ee9c9e002ac68360acb67f67b881eb857f7f82a75;efbf2cd4659dc4bad7d52287383ffcfbb97af2d34dc9b67eb3a0f979e0eefbe2;10060;2;2026-01-26T13:06:22.501Z +3d212e9926065e44cf233e080f9a1cbed90ba21e86a5efc9709b8e419f39f340;7adbcf012592fa70cdb57b397b2bad7124926569e3747787f91ccc4c37e6fc1c;10061;1;2026-01-26T13:06:22.522Z +8dc7b08f10c78f44b403650ce92b7205cfee6e70e032a98a99657cecdd744fad;b76f1ec313fd8620749d7769ba9932499699fa0c2fc8afb7fafef7e30460cb29;10061;1;2026-01-26T13:06:22.527Z +faccc87eaa2df26eb5f986e9544be68443e951503d9c3b5784b6d88fe4e08c50;e49a6089a43637ae07fa6e66ad2eec5587ec6219a6415b690326f52d30f72a54;10061;1;2026-01-26T13:06:22.531Z +7519e23a81fe0cc849dc07734a05750c6bd15f1084ca57558394046db3486117;ea201af5f5bec690261b44e693ac9b18d70b66cf31cf4ef0d713fb23947db493;10061;1;2026-01-26T13:06:22.535Z +0e5aa0ded46a30413b8b33248ff82bf1c1a0e163f1c3b2e3d3ba511058134f1a;da186a1165be8a6b5814521dd9c95707aa807e5b60ebbd257951deaa94c14595;10061;1;2026-01-26T13:06:22.540Z +0fee0bd29378bdb2d4550885367b07030c8bb8495e50a1f5881e2f204d73c464;2737e8f6f5d765f8a5b707f4a230b02d2e294ca31d29acaed26d8923dec93aa8;10061;1;2026-01-26T13:06:22.544Z +b4108e73b5f78d60ea1d251175b3170fff1915ea934dcd774e82c130f346d717;75770bb78a3d182f5068fb175ae223b0eac40b0d3d466bfb68d453d0b698e8ab;10061;1;2026-01-26T13:06:22.549Z +5965076df1d3b0897852c3913fd01ad3ffd3ec36f90c37582a11c5d6667ce758;2ff727c27bea4e0657d52eac0011df97d95f2e7f9ab0773afb29e19282c063cf;10061;1;2026-01-26T13:06:22.555Z +c97a498b6e6141b10606de1f86dfd53b8d716e1a13b40df081d0eacf8e4295bf;9dc4d6d4f8a0237e3eba0c1f3ccbd06e3463147f0e7cc9f8c029092b122c00e7;10061;1;2026-01-26T13:06:22.559Z +11149aeda276ba4679a9bfb670d77a40bfa188825ab6a30cbb05aedc9c2972f0;7a05a5f163eecc3402a7633033c11e8eb5e40da0a97a5c757a0d440d37c35517;10062;1;2026-01-26T13:08:22.447Z +97fe1b3abfe91d0dfac57794d463728fb7f1f8e6fecd8c5bf540f3e7727e79fa;83310612110c6feee90e5738d1cb97bcffd52bf290438acbdaf8bf3db4d8a14f;10062;1;2026-01-26T13:08:22.450Z +33346aa951fc8449f07f850ee33346f223ce5117bf212de58b7a6e59fab05baa;6afcd3b77c41ce11e430fb0defaaf7841fc60612a221de94a30304e29c62a454;10062;1;2026-01-26T13:08:22.454Z +3ba9acd0e21f65fb095074164d3bb4b386f37dcec37f679f2ed476df69d94f14;578693cdf7c1f0089037999e30b47881ae4223b72cde16704f5cf0010cb5d356;10062;1;2026-01-26T13:08:22.458Z +2053d91b9f67fdbadab8a84c933a8cf89e937d6703e9f585d171a17c4419766e;e9276c00e054e9c9ddecdfb72240e23042867f33db12e1c6b1489e632c466485;10062;1;2026-01-26T13:08:22.460Z +9926e4e2828292bed111bbfaa223380b7268db9296c26e8a0c761d47a3f83145;a724ff549a4ee045c19e38152db2906902a89e639f8549d27d1f87787539a985;10062;1;2026-01-26T13:08:22.470Z +edb5d775fe14495b165d6472d9537995e856095abb8284879934dd95efde9589;ac1ab03e1f26309f669b97cc42b78629f69565369607caa6936188595664dbb6;10062;1;2026-01-26T13:08:22.474Z +077abaaf906792332d8c0777a5dda0de1c66348538a27667482fc8145b2f3406;a321261374aaa4cac52cd08faf918cebbbff4b47e6bd8868c6bc1bb7239b57c1;10062;1;2026-01-26T13:08:22.478Z +00f31feaed4fb9bfe54cc4df7e3f162d1a701cd0e4dffe7762ffa1d33e2045c6;e88912f5429b73f3eeb32dfec104bcdbc4a6697a6618c920805c369e9b8cb6e4;10062;1;2026-01-26T13:08:22.481Z +4e11a0401f8a2099599d02dc4bd1e99a3179f3c58ff42b0f0e3fbeef6a6b68f4;dc3bd18916452b4cdc5bdc506d7aca7c7a878151cb6807f8df33e7755eb022ec;10062;1;2026-01-26T13:08:22.486Z +c3e451c0d261913a31c8f4ba1f52a172af2441f69b7684afdf6d6f116651c4b2;a2ce333df12cfcd25b1abbb76a0d20f8b37d6769b24e697452bd8a8488ea78f9;10062;1;2026-01-26T13:08:22.488Z +348d6dec21338eb7e12b9b8301b405395e7ce5db50aadf474e8bf5d328bfa291;97d25634836599ebabbdba01e4bee3cdd54317b6b67124e91388d0f21d539230;10063;2;2026-01-26T13:09:22.470Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;d62799ded244b3489d2dd2dbc0aed3ad1d27cdb966fd374b9dce3fb5b8bcc638;10063;2;2026-01-26T13:09:22.473Z +ff8ecab0f89f419907a8e24e7cb45694ef709a9879cb0d9cd40879c169a7993c;e9df287015670eeb66d3c0c7e0612422f26bde220fef7b6bd25a42bd55b4ba3c;10063;2;2026-01-26T13:09:22.477Z +760903e499eff6cb5ed0f62525f05c98cab8a1bc509bde5a6f46abe66428a844;0cb6352e7a5afe08823e4ea5d9a7001cda42f8576593bace02c8882b3cb21441;10063;2;2026-01-26T13:09:22.481Z +86f12f86ea11bf65990f4be3680869613929f6d8885bee49ccdf8ab10a138785;363fbb84aefca9a76bd8704eeca0dc2567ceedfc759b3e128abcf9608614a445;10063;2;2026-01-26T13:09:22.483Z +511894b1d15b6a0aea5d227b4959ca95fe9149ce224d291a14c771ffe5f71e85;f521c3bd9fbcfcf8940271fdf43fdaf1c10b2e6b5b7cdf4dfe8b45b73e1d1f4d;10063;2;2026-01-26T13:09:22.486Z +d88e8d61dd386e07fd0f70fb5d315fdd8e1f3aedcbdc0b16690464499bdc7442;207b768d54436df53b936416a09fe1151fd76aa4ad2ff363b79597359edaf46f;10063;2;2026-01-26T13:09:22.488Z +7239962460fa2b3c2beac64be9a70dd56fa53988d272f1c8f8a61d92686d3815;3a16f63d78305627158c679cba2c3b0d777d2da093153b846be84e0e9ab27582;10063;2;2026-01-26T13:09:22.491Z +84f0018d4f8f352f87a2dad9c594dfc56e563501e6ed0851e12797127f76b768;f6d6a790efcd24f97641342a771a35c4434ccc301ba5a9b78bccfed510b96c97;10063;2;2026-01-26T13:09:22.494Z +869478a5912d2c6bbb7ac9d9372b9356f54ee389aef6cbdedc88fa4eca2130f8;3427c1ceb7c566250a4e214bec14c8321553743b810ad78d9ee7108d01d10798;10063;2;2026-01-26T13:09:22.496Z +5fd61687c9ca4eb155630726cf49a4b4972c7320ae60a0273c946384ba0b5b8f;d048bcbf7db7c12dbf5e330eef88e50f25b4a7c49497317d2ee97dae06b3cf9e;10063;2;2026-01-26T13:09:22.499Z +e916d12a7b6b5abe947b6b103d00194c1ef830ca6d0ecc29505b1f1c66826f99;c47b6747283468857a00de31a060b3baead582c02aa63676cc0a18132d2ad2b0;10063;2;2026-01-26T13:09:22.501Z +479bd5ac60ac7d2789f5d633c34d2d3388e80c5573db6c082ad50b0e84cc7ef9;3364da23dee9326cf35f80d03df6e0d4447af43e05e105f9871debcaf9ae51bd;10063;2;2026-01-26T13:09:22.505Z +6807003629a733ae572a0c1fdbe80c4acaa7c9d8eb3f86856b5b56ec7475250a;3ccc5b6a70532f891c732e9a5f7bb860f33d7ce1a802f7344edabf7b9c92a8be;10063;2;2026-01-26T13:09:22.508Z +a3171a357ed5469a81933d013c8349815401fb63a7fd87d03bcad53c07adb0be;c47aad001ad300689eb9ee677f5d04e55c23afcb318f58e10a7cbf40ef9e97c1;10063;2;2026-01-26T13:09:22.510Z +1dc40d3b5d994ebf83288bc6046c07c7cdbb7d465ebec11268439061e55a75d1;e532c8d13e0d124e91f89fdc4ce76c519d02f4ec210c6725e54ca39c5a24fcfb;10063;2;2026-01-26T13:09:22.513Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;1aa447479fe8df1e1b6536f23683a62bb9a118666e852619eaeacfb169c1d604;10069;2;2026-01-26T13:15:22.418Z +29284dbd36fd123d66245dcd06b1db835f2c1f0d097ca2026f07f3f342cb2dc7;8dd2b717b56de3b1a301452b77da989a0498b8eb19eae8e78765e7a70f5a0e55;10075;1;2026-01-26T13:20:22.398Z +ee4f2b50ca883966a9ae0bc5cf3456a41c55017560677016a92620a30c365677;5e1129a7665d0ce5b61d9a9459c89221a6e3926cedc2c818c8b00e5a5ba05ecd;10075;1;2026-01-26T13:20:22.402Z +eb89b67e9f8dff45be334f4cba27fcef7bd67d298d758914ccab37357e61c9b2;7fa1eccf7b274e531121a43f999f270e7629a24e1799c5b50522e756bf0bb3d6;10075;1;2026-01-26T13:20:22.406Z +d4fb8a5adf7c3383584648295334f7be10bda4417dbf5369a98df1276002bf74;e7f67622d324f96c0d354bc9db3968b87c476aad7c8b8a12de3dab3f2cf7f602;10076;2;2026-01-26T13:22:22.369Z +28b81be38bfdfb9e3a96189f26cf8440a7cbcb73d78e9ead5f8f5ba171cc989e;989057c2124439d55d7d23a84f685482c3d5a21bc6bdb69fea0f08da7cfc400b;10076;2;2026-01-26T13:22:22.371Z +4bc0d5882a30ab956a49b27074ee1d9ae2bb1a301d20edec9327c9622c1045e1;d93d60f961ef9b6dca58965ea129b62a7f1d0c7e01a53b1bfed2e4d23e2da00d;10076;2;2026-01-26T13:22:22.373Z +6334f92c3d0e0224de56dcaed3fd126c3a809d75f0996adcac6fa04f5a7e3f6b;dac4322268e0e854ce9e25a58eb67a34cca6a484407a2a79cacbb16d627d682b;10076;2;2026-01-26T13:22:22.375Z +d39de468595cb4003f4bd85ee8cc3bb1d579ca6aa683ba1e5cf9bf76c0a8759c;75c320fdec659d3bee55b0458f227bb4b34810686100e39bea0dc2fc469a193a;10076;2;2026-01-26T13:22:22.378Z +ce1f23779d4161b0ba66dc3851c41affd9dab61bfe0747a30460df0606695953;9213d52519fcd5fb9907c0d28a5fc13253f60c420fa97db9b6ccca9097e2f23f;10076;2;2026-01-26T13:22:22.380Z +58138b833be0bf5b655f5ff38f57e2f6ba3e77b2b5d138c408e51eb5517e7a0b;cb8fba42762568d42aa0389d2c29a7aaca05f7f893774540bf7a6f72b4442540;10076;2;2026-01-26T13:22:22.382Z +19ae45319ecb8bef0c3ded77fb7e84e84531efef0ec1913677c9be43d4f56143;f0cc3fafb774acb00d4dd04e44ef4d122fdd5d7adc44f07d843054d47f07d444;10076;2;2026-01-26T13:22:22.384Z +4ae864998e7799a76f28b4777982884ee0d22880deb780ebdce51efd522de2af;1d80606e3cec54dc4b2d1e308972587669ffe5dedac46c2de9631c422f757f4d;10076;2;2026-01-26T13:22:22.386Z +71643c955c59d673c2fa56a53243037a89f3b22fb4b46a9b1f7340b8f0d642ee;c713bd7f88c36dafed5f44eeaca3b31fc2c8f119cc06abaf9f122daf5ea27157;10076;2;2026-01-26T13:22:22.388Z +9536a1f49750ebadf379694944ca364730e8fc0beb0d46a88a24c58614e23c38;bc24a20d8757c00db9f72b281a92146a64567e6228da9c60bfd4dc185716605a;10076;2;2026-01-26T13:22:22.390Z +8bbfbf339d2df331062c65a942670054b0e92349e0a812caa30acb039cbcbe28;aabcce523d16f380455a1f3aeebe16d553331e2d7700ba556420825b6fe33567;10076;2;2026-01-26T13:22:22.393Z +eb73fbb26543acfb04b11c51787567d1f06291e1f9e1914f53106d12f329ec7f;9b30856ef3295004abb4d8f6806689d7ba71881af6d0cad0d39949cab8f8fb79;10076;2;2026-01-26T13:22:22.396Z +1280a147a99b742f6f8e38787520ed8a9a9d7d01201331204bb271cff548a4a3;ffdb26a6bdbee2a03cfb820aed84456fc0b9d142bab80897abd47655a84c257e;10076;2;2026-01-26T13:22:22.398Z +34413517c3c83028546951f3c5268b72d39dc5a2b5eaab5fdaddc8f3b691b910;8d5c1a1bc63d03924b5dbd0963ed7b25c183870de5bd871391015c09d762eb8e;10076;2;2026-01-26T13:22:22.400Z +05daef882f238a11e60c93049edc3add549e0d3ed6001fcf92818f2863ec43fa;19ae7097e7ae5a3a68eca3fda1a782ac178494faa86e2e3887ed72712e7e5990;10076;2;2026-01-26T13:22:22.402Z +94e24de41afebcf9093c081c8a866fb47975bdaea06bd81370201905ee39f780;d15b50a07078fc8df8e084ff61d373ad29fcab64c24f79f2fb3ab8340f74429c;10076;2;2026-01-26T13:22:22.404Z +d7d47c99719bb39c92bac96db42eaade5e22860404f8f4cc8fb6ad3ed171af5b;2207ca4086536e33d0d5b6db474903ec1396d668d82aca89f2e8eb4f5b94b5ae;10076;2;2026-01-26T13:22:22.406Z +d1d428e11cbb511a683490c280c400efba9de3a6abb34baf2340be4672ca2df5;86f4e9d727113b252c27dbc1ca0e3d14b21f72b78570533886f5548f4a77dfb5;10076;2;2026-01-26T13:22:22.408Z +665a9d17e86df762e1548382311c09e45150942d4c8fb0731a37812f8a808fa0;eab04d97fcf85465e3a97297611bfd692a034ad758c68ff2322153d5335372b9;10076;2;2026-01-26T13:22:22.410Z +5797158d853b2be55145155b72add5b23af9e2ba38c91162e1250221597eb7d9;90eb430ea5f7298f9e71814a07018bad87b9f47446dc0c69437480d05de446d2;10076;2;2026-01-26T13:22:22.412Z +3475943cb4fc7b3870d99f5cb622c66e20c17404ef50e1185a3732b0f6e3bf0f;1925f57da844019fbc55f4c88fb2995600b871388e48406dcfd37fe9dc546dd3;10076;2;2026-01-26T13:22:22.414Z +9822fc3feae0e2d0e409f7809370aa7d9abdc7df1a0b55960b66866f88b5aac5;2e15a140fbd01ef8df53a288e4af88fc1758fac379ab3af8bd0194c037f090d4;10076;2;2026-01-26T13:22:22.416Z +b408f5b4b42d110250777db1ab016fdae56821ab0de52d67a6f90e8947a3a020;455ecd38464beb22fdc5fd5cd587e8bfe964a8e40f2311402ed0c586e89e53d8;10076;2;2026-01-26T13:22:22.418Z +c067030ebec085e8c3d91a59498f24561bbdfd73c882b82e2ed897286237d729;894cefca35c6157d0541e6fc31ddf2b72c7b5f9a3ad3f893edaf0b6e619d5cd9;10076;2;2026-01-26T13:22:22.421Z +e54794ed6ef4bc058bc7bd8fc28126b76a39bed2f7d1d4ec21ce4c55fdbcc39d;1e4e67012bec57d02159a7dbaf22d9fe4021b2ac83a172e54b6a5948f5bb18e9;10076;2;2026-01-26T13:22:22.423Z +3f6846ad574e3e79076b70161bf307ee0dbe9c0bc729d6c9a0c48a4c8eea77d2;2ba4eec6478745e3972625e9743ff44576370431df83fd0aa24d54ab8fae07ee;10076;2;2026-01-26T13:22:22.425Z +463a11a2aabd2bb384c4080763e424c662c9dfd1b12e15f8ae5ad6ac3c9d05e3;78d26c5b11fdc1b9eb04a388cbb14c086e68e7233c3ae3a4aa9e26a7348f9bf3;10076;2;2026-01-26T13:22:22.427Z +13d539a40522cfcba19acb1f5a35c6e0549283c62b8fdc016a9ad70b127a3c23;d0eab1f9199b362b319b1b7723f3d6bd008b02922142686a97f705063bc97904;10077;1;2026-01-26T13:22:22.443Z +4a122c665d5c3c13c54bf295129179a167bfd6d5f0887f03b8641d23f1e2c4c8;0b9f77703bf806bd6a90a5098c8a3ff618bd19bc0b2c36dd91e5e23c3cb2b117;10077;1;2026-01-26T13:22:22.445Z +f3477cd39c8ef70b3466d89b19d52debeae08296c812cba59fc62ee5ac63a3cc;9bb3349749aed4d8c08051df435d4448b5ec11186e66399b5956455575a3601b;10077;1;2026-01-26T13:22:22.449Z +5100371c4804cb88f252a47c40907d886986e1b97bb0f88041e12634c1c11344;ed301145bfa181bd56502c2d6307d6231533d98b588ac6d53e86dea0314c532a;10077;1;2026-01-26T13:22:22.451Z +a0ed5c723bdbbae8f9eee3e38d9ba718af60ff322ae05cf01c66f937e720c0e3;0a3b3071cd779d51be5d534b75089394c267a10783c29b0033c20d494b7aed57;10077;1;2026-01-26T13:22:22.453Z +cad30c4f5206ad00e7dcc88318b790c2f52dc7676ae3114c7898f4a8c9b243ec;348606bdc26a62cc867f2e63dba1c719213f15bdd83a75356204d39f1d4ebc65;10077;1;2026-01-26T13:22:22.455Z +1cad2c073c941d9b96ba67f8b6590028ae8f0709c27e99bf3d5e6085e5ddced7;265ed1b69a462bd156248f9df627a0f86d78fbc1c083ff97463d6c1b76666b75;10077;1;2026-01-26T13:22:22.457Z +cd1d1b4340c464e87b356b00c2abc218b89571e2474b044bc719572bf60a1244;b55642f9d29f1a4a1911fe09a6408f430b0dbe64bc1ef1a1e99ee64a16238d7b;10077;1;2026-01-26T13:22:22.459Z +ffb4eecfb4e6d9f2152dccd1106a484a4f1b09fad9ab70f11f9ec68c86a90834;004c86472252484a9ac5df900d39e03871e99a5014ae5461fd1ee04e55ba03b0;10077;1;2026-01-26T13:22:22.461Z +a7268f32dcc82f0d32a0a47edf234f9ded5999ac322e533e35b5a9f268f89519;6169a1c9a572a0357f3063a845881a7d7cb43b005ee360667a5704aa5956cfb6;10077;1;2026-01-26T13:22:22.463Z +739bfb9090e20de3cb9c7042e3de22280014c5e14840b9df4a1845f23a1fb4fb;8f5251d4965bc13f2bab23009ab13021dbfdccb995b8d0efc6d117e8191dffb7;10077;1;2026-01-26T13:22:22.465Z +8647c1b7e00bbec76edb93a6c7a6fbf62845cf9a75a351b5f53e948958a7b60b;1fd7b55bf56bc3f9a483a3a1304be75e4cd8a331b062f5635c67dee1bd2726bc;10077;1;2026-01-26T13:22:22.468Z +0a5a214570557e4d173dcb2200d0cc411c7f3aec71e8fbea11c6dd32f612e693;63e8bba641d08974520948d38ed243e854899dea1ea2552db5f636f0f62626bd;10077;1;2026-01-26T13:22:22.470Z +c34d09487c91e536673d55a4046c345689c23365de5bed25054b5c3fb5a39356;0a73a9671c3e01c0d24ea20604b6df51aa784a4cd491f4b8286d8527a8e85bc8;10077;1;2026-01-26T13:22:22.472Z +6e631ca1e56e3f0264e9a59a86f4ce934377cc1cb2c85f05e6ebc11b5e94fe46;1e9fad8fcc155bfa998d6b1cac4ae1c1f1623a3ce25d3d985a0bfcef586eb9d7;10077;1;2026-01-26T13:22:22.474Z +4f00c30d951d5c6dfda886ea908a76828166afb031570b4545ef2d69615cf99d;d912880113e824e3fa43139335a4ccb62a462d70b4d99769bd1e2639d50b9adc;10077;1;2026-01-26T13:22:22.476Z +45a47b97a3655560315de7c74f3de67bfd23263362f6b8b435598fa006f885a4;e7dc7187c755513b673d3d2d7bc0505be76e68e2e1ae9e6dad3ecde9f7d79fdc;10077;1;2026-01-26T13:22:22.478Z +c0069805470b41d2941e4c5b686cfc4bf409c65e88db216e0e110dcc571e592c;8adc04cb83318d848a7ba90b5f19b7374f4f6b7ef143332a28169d6ae5f7daf4;10077;1;2026-01-26T13:22:22.481Z +e27042391ba1ee610ddff37def5dac7411e3ac64ab8974f65ac863fcf0ae15c7;ba0224a0a65f8741fc576d3749514575d3e88a7886873972ef42eee7ce1fe4f8;10077;1;2026-01-26T13:22:22.483Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;92b296008e7e0e8d8615d7583119656a89680de8e5ff554a3a176772f04fa907;10082;1;2026-01-26T13:36:21.835Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;a7cae1c0723583b37d9bd09319e55a5e46c4eb95a53e6ce004f0ccd783891515;10082;1;2026-01-26T13:36:21.837Z +d12114492ac7cdb06e63d9b5cf248f4896cd7e7ed304a2dbeda398de14ff9fad;3ad2510330c195d48f4b0c1ac13a73d77974cd8741325a2cbd2d5cb247848a02;10084;1;2026-01-26T13:52:22.329Z +86210d7719230a5cd40afdd9a8d4472fb6c10e3b2acdd8a6a9c10b6f4189b5e4;85ecb639df1942444fe5348cd4b91afdd261218e56e6510026eb75e93945be03;10084;1;2026-01-26T13:52:22.333Z +0c61c9e9a7442dd8df60128fcf105c09afe7d56cae5e742e80ed9176484e112a;9779c2a8d44f695da159f24e574c680e5ddc41210c11131198413f2422eae70e;10084;1;2026-01-26T13:52:22.336Z +decde15bffb205751939d36dc28e2dc0707fc7dec903ed42b0c82326fd7dd2e9;ac9e27eace16d48309d38c58413a3c6dffacec8cd945a05f0ee921523fc7a125;10084;1;2026-01-26T13:52:22.340Z +77e2e0cf0bbe60081f6527940467754ffa084e53f613541510d301a8cdf61268;a6403296f2ff72ba933bc0c6c2a9aabcee120231ad40ebcc881cf6c0c167cd2c;10084;1;2026-01-26T13:52:22.344Z +1a3ccaf9dfb4752b1429c4da73837827160f2b7f43ca1fe53912d42a05c4683c;857bd31e766222fe1ede8b20def72ed106a4d249290ae9cc5fd72633d5886937;10084;1;2026-01-26T13:52:22.347Z +df8c12f6cc3430ff7c0cc6d49b87638c8532315f3e1a6f49f9f248a4ebe518d5;be88a857ec3052e9253c57be953817e08a7ce4430c1f8f8cfed15278e6654238;10084;1;2026-01-26T13:52:22.351Z +12b9eb4a7c6a43c783de9bd0f98f8d384e9676ba7e53926ec0a2a316ad7ecb31;7d298ea8ddd1479daec94cd49963a6c5c46266673bd5de0bcb5d37390e862d39;10084;1;2026-01-26T13:52:22.355Z +388cece8b03f45a255213d9b1dfb394ab3bb9232a6707d9fc94513e872c7d53c;0b4af26d735ee321e77d8bbeffa0c864820e8c4184438ac7f3d2b782d14ac13a;10084;1;2026-01-26T13:52:22.359Z +a66ca5d792e8d97313a590364ee7e6ab1305c10fe9a2b4be8f73b525b2f14772;4e017c3e596935c511733d1c4c3f4d3ac1823f553a89559ab698fe365905566f;10084;1;2026-01-26T13:52:22.362Z +e7d43d1558132596e88beaa84b55f5c327dfc6aa213b836585d596a93dce4e75;14ca7760d8531c5bc8d571b0dacc7324e199fb9fc449623c87161f8536f73d71;10084;1;2026-01-26T13:52:22.366Z +640221e3d64340ba39144c588dc57a0d9dad29f2ce825f2a0baedfa6577926b6;17db0d4b4dc60953ffc130a5dddee7d32a7b09947e2964d0f3ba1c245c430f83;10084;1;2026-01-26T13:52:22.369Z +877a1f9542fa33268431ad91002500f4e2fd88767976509e5bee596f64d994a4;1be46f1be24a519a69060ecd082872647a46b70f16c13002cd20d539df4a428a;10084;1;2026-01-26T13:52:22.373Z +2e53630dd33a27169a728cd0df75dabe537776fcd8d9dacb711e854ea2ad28d9;d80a3911684e25478834d7d64396bd9ec03f2fa850d79ff7e482e9c0c0e1a094;10084;1;2026-01-26T13:52:22.377Z +698c20ead1f47313bd6fefdbf1f7bf7f23edfc9d73e1dfb2783da573ea4c303c;efc01359ba732f8e5edd1b1bf2a7463faed985778e9bd35740de5ede3351d394;10084;1;2026-01-26T13:52:22.380Z +bff06b11c21b4212ea906c0d33ac3531fdd0fa28d8bdb9f17b90b56fee2dd825;d127deeee1507dc1ccef82d5a20b707c2429173967807295f38aac5437cc4598;10084;1;2026-01-26T13:52:22.383Z +76ad4da418d1729efd41daa0f029eaeaaa5c9e7237a37305ce895319cf7d286c;e70b70be4513a0c403415b17443a7215f02f62113be5efecd332732e0bd5c1a5;10084;1;2026-01-26T13:52:22.387Z +ca902ab65676c0d3f2dce1a94267509ae460efd8d32cc31a314c9c686d7fbea8;933c221c99e16ac7b604919b77dea479d4f8fec8f93e38534ce6acfce251dbaa;10084;1;2026-01-26T13:52:22.390Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;67e4c94a1ff175c81b5cbb03e09639fd43f16cca807d21fae46f911e0eda3dbd;10084;1;2026-01-26T13:52:22.393Z +6052153a53669fd9e53ee774c62db01aa7a03ea3d7e950fd951dc25c66953de6;3df3e9f13ca3943fb64d161aef1fc74f5734fb57c7e8990a7d549e860affb3cd;10084;1;2026-01-26T13:52:22.396Z +f234660475ee9beb546304ad97870740d41e4ef28fe595e86e3bec223669102f;d517d7435414970c6037095ee4f9152d027325745fc2b2a09611338bfdafead1;10084;1;2026-01-26T13:52:22.400Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;2b3dac891a8b17406f9c5c3d7fbb2e49eb387c29ef4b5df86e113bc7c09331d6;10084;1;2026-01-26T13:52:22.402Z +50c4d20dfd2d55a4ac365689377fd7bed1e3830ecf4c00f4c2846dfb182d31f1;49746db5d89877d817a7e4746ef07fa9010a966b09f3b60ee8d6d94ae2e27dfd;10084;1;2026-01-26T13:52:22.405Z +0e4747f1d803a7f576c4b4809f95a9e7b018635b3f4685494685d8c16423b446;0136e453bcf3c07a0f5584f120ebeaa928cb1d5bbb7b23dade9f3114b10b0704;10085;2;2026-01-26T13:57:22.265Z +79e49866df36f2a54a68e8592e7b8638090276709cc190af44b70f5d361ef49d;a1d0023f13569dc60408cc61c41f5fef4f6bddc1db4a1e4e5269fbb255e5b50c;10085;2;2026-01-26T13:57:22.269Z +9f03199a704b168df559a511986f8840b73a9fc0a720c2d8057427951e30b6c2;79ca05dd9747e44c31c3c851b254af5a84196111e7174a42fe00664dee6fd80c;10085;2;2026-01-26T13:57:22.273Z +f4ce4659fd752df5b45c29174de555063b35ded4e1831591000d7f31915bb676;5e3fd222b3a048b3ce4cbe94b499d234678adbda114b72b1a0b0c62162d0882b;10085;2;2026-01-26T13:57:22.277Z +5357b1e37d32881d5a7c5692e94cfcf25345d8fc52e35085ed1100070c9910cd;6f7b10b04e0cdfc0db92f9d14002c43f4e8da57fb6af76e06645d3c49add702f;10085;2;2026-01-26T13:57:22.281Z +f79a450283f5a1e0cb95c4b6bf44d00220488088fcfbc29ac067ea0c4cab6e1d;d003afb43152427432c133928c08705269648045d85db1c1cc4a233ad07c653d;10085;2;2026-01-26T13:57:22.285Z +8f174ff7f171e839897b487e9469c0eaa1b76b340a33f0899cce1120d48c8617;a6707d085e40c8350803aea5e1bb6f8a501a66661dea5324a83fbfae9fc0b451;10085;2;2026-01-26T13:57:22.289Z +090f968f0b956dc935aa69c8cc931231abb226474af32c56e50548aaab8f9929;63d2269b86637d01a65f860cb960ec8cc46d3d19ef14be6de192c0ff28fe8a55;10085;2;2026-01-26T13:57:22.293Z +c12b4aeeefc21dddc1784189e6fbd017dbe5ab622896728f795aa488347469b7;c487acb090df9b04454736eb48cd78aa4be5b2a0f2e8e7902d453a165e82ab55;10085;2;2026-01-26T13:57:22.297Z +f384d647ac98514b721c76cf8710018628d1db0b41058c7228772abd4618f41d;21ac917de4fce85261f5b0943bef3e980ed92a27af123c588f0bfbc376e1b564;10085;2;2026-01-26T13:57:22.301Z +2e02864259cab3745cf89098f5a89d7af347207bca1e44492d7ce1ffe39d66a4;c3aac2cc07df2e3113eaad6919799656f22796979e987d460cddd149a4387976;10085;2;2026-01-26T13:57:22.305Z +307d875d053721800caa6c94b77432330ffb5746e9a58bc11efea2d6ed523bac;951c64067969d0f883509dc12df5559a114ebd3b175788c2718179de8483087b;10085;2;2026-01-26T13:57:22.309Z +0d679f9f38f283da47104cd2c20b03c2078e72f0615927db182f757454470596;20f8d44c4c80d18fdbf944952aacb77473c830eceb5b8a03fc3ecc8e8a74a57f;10085;2;2026-01-26T13:57:22.313Z +7d59e497518716b093a6d8bb020f759f03600f13eaac37674b6c7c0c75de2af4;0a0b030cc3c76e5c1ee21dbc9ad92163d7e52fc7bbef58df5de9c3eb58718681;10085;2;2026-01-26T13:57:22.317Z +74b01ad4f18b86dbd0e03bf89d1c6933d04ecb21bc3e5e4813a86e09a602ca3a;0f6dfeccf1526419a0093296d467cc9d9b5eed0ca9b0fa70648af625f4b99c8c;10085;2;2026-01-26T13:57:22.321Z +1e375792668cd35c4b2181e5fb7616da16ce4732ae513db1eabfe6b7d8830918;82054fc0201affdef02d7f962165ce15123e873aad3c9b5eb0b0eb4f84789591;10085;2;2026-01-26T13:57:22.325Z +b0a692730fc6dcdd9188b0a3d44573600f1bf5b48106b1b6987ceba87d1fcb55;50f58dba42c5dc806603b665010332ff238832222ed48bc7f65ef864e65f4db2;10085;2;2026-01-26T13:57:22.329Z +6b3351529e8fef1e590c89af27fab254e9d0f014397c193f6120ff12184e7c3b;6c3e32519a0d5a3ce1ea6710f260934304f1e796b34ae18a0a8c157a9a9f5db7;10085;2;2026-01-26T13:57:22.332Z +6188c6ccb5bd270f29d94d26f8a03a785fd1dcc47a3c183ff0d60184220acecd;a76827df05a4bfbce4b6549534017f1011e7f85e49ff3af32c372e209a4090c4;10085;2;2026-01-26T13:57:22.336Z +dedd3c148390e446079aa06ef2bb9c1b10419949cb77b972f621a0ee45433cb4;edfb5fc6f84edddc0bd9d6776d01fcb673b82808f9ae8614e5f338648418ecc8;10085;2;2026-01-26T13:57:22.340Z +f2b66d13aac2aa6af11f3182a4046e4cf5c412635cf3725470e005757d828c0b;1d7b034970b9142b8ff292af1ccfd55d921cc7948d6651ca1dfb1ab4da851ace;10085;2;2026-01-26T13:57:22.345Z +7c8bf431d4e0c5d4aaa8fff128d2bb05968421fa7cb39588f0700bab12acdda4;d1bbff7e38b78e948e1fd5c72988fd6ae3466be5883a1f1dcac928b364459dd5;10085;2;2026-01-26T13:57:22.349Z +4f0cd6842670f4118b57c3c75b9b3197068780d8f0e0529296fb1460bc2fb3ac;cfa9005918138fad4a63969590ef08526ffd3a5a193fe74d301e6a8a8f0a69dc;10085;2;2026-01-26T13:57:22.353Z +3e5f302024602a9f180ad1ff60350b35c29e97fc219599711d9f566e605db235;bd7eac69fd60077d4e4f7c3ee5c5229a9c675ef02ac7aefc59639e618c4061e4;10085;2;2026-01-26T13:57:22.357Z +cd969d726a3e4499c3e7681ea78e074038d8c0f3dd915d0aa066ce01f446c84a;f2128d50f0b5412e0b917ecef25956102d23a43d4ca3b7163e59b71975e0a8ec;10085;2;2026-01-26T13:57:22.361Z +0251b9929642405b088fb86ec491681bed144e2441d93a78a02e48cb663c2ba5;b6d13add9c9a8b0d864fb48b392f8c268dc144312037e78eb013c5361daa01f0;10085;2;2026-01-26T13:57:22.365Z +27f0c7453791ff40e7eddcb95a19d5bed1fb02211dd7ce3866ccd776d511e9db;7ebea36c902b2460603b816cd743978673677022a7f418eeb79dba72078e38fd;10085;2;2026-01-26T13:57:22.368Z +6998185e97f64dc64d1d8b6963fd9520e52d7d579cd9bb60548395a91800dfef;92369c3c0ab0bb80c77ed60d868eb1836d578eab180fda50b6c30d5bb16803fe;10085;2;2026-01-26T13:57:22.372Z +1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;a0832d9512c3b04b5fafaef612c9969a5687f5ed7e5f0900dc29122f79ad7e5a;10086;1;2026-01-26T13:57:22.387Z \ No newline at end of file diff --git a/hash_list_cache.txt b/hash_list_cache.txt index 98e57d6..7b31f09 100644 --- a/hash_list_cache.txt +++ b/hash_list_cache.txt @@ -1 +1 @@ -2026-01-26T12:59:22.551Z;10051;0000000d02cc6dd24f8c8773b4ce967dfe284705296a141d00adcaf95b9f1d5f \ No newline at end of file +2026-01-26T14:03:22.233Z;10089;000000035b7f3ee40fa24b8767cd80a5f138a2236540ec29e37f4067008ebd6a \ No newline at end of file diff --git a/userwallet/docs/specs-champs-obligatoires-cnil.md b/userwallet/docs/specs-champs-obligatoires-cnil.md index 78d38b8..e22ccfa 100644 --- a/userwallet/docs/specs-champs-obligatoires-cnil.md +++ b/userwallet/docs/specs-champs-obligatoires-cnil.md @@ -81,8 +81,13 @@ Exemple : --- -## 4. Références +## 4. Types (userwallet) + +- **`DataJson`** : champs optionnels `raisons_usage_tiers`, `raisons_partage_tiers` (`RaisonsTiers[]`), `conditions_conservation` (`ConditionsConservation`). Voir `src/types/message.ts`. + +## 5. Références - `userwallet/docs/specs.md` (MessageBase, DataJson, Champ, Contrat) -- `userwallet/src/types/message.ts` (DataJson) +- `userwallet/docs/specs-champs-obligatoires-cnil.md` (ce document) +- `userwallet/src/types/message.ts` (DataJson, RaisonsTiers, ConditionsConservation) - `userwallet/src/types/contract.ts` (Champ) diff --git a/userwallet/docs/synthese.md b/userwallet/docs/synthese.md index 74cbd4c..5c5638d 100644 --- a/userwallet/docs/synthese.md +++ b/userwallet/docs/synthese.md @@ -15,6 +15,8 @@ Spécification du login décentralisé (secp256k1, contrats, pairing mFA, relais) : +**Complément** : `specs-champs-obligatoires-cnil.md` — champs obligatoires (partage, RSSI, CNIL, cybersécurité, support N1/N2/N3, etc.) et attributs CNIL dans `datajson` (`raisons_usage_tiers`, `raisons_partage_tiers`, `conditions_conservation`). + - **Modèle** : messages publiés sans signatures ni clés ; signatures et clés publiées séparément ; tout adressé par hash canonique ; récupération par **GET uniquement** (pull). - **Objets** : Service, Contrat, Champ, Action, ActionLogin, Membre, Pair, MessageBase, Hash, Signature, Validateurs, MsgChiffre, MsgSignature, MsgCle. - **Graphe** : Service → Contrat → Champ → Action(login) → Membre → Pair ; contraintes « au moins 1 parent ». @@ -52,7 +54,7 @@ Spécification du login décentralisé (secp256k1, contrats, pairing mFA, relais - **Pairing** : BIP32 UUID ↔ 8 mots (BIP32-style), `PairConfig` avec `membres_parents_uuid`, `is_local`, `can_sign`, `publicKey?` (optionnel, ECDH). WordInputGrid pour saisie. Confirmation croisée « membre finaliser » (IndexedDB), statut « Connecté ». - **Graphe** : GraphResolver avec caches (services, contrats, champs, actions, membres, pairs), `resolveLoginPath`, validation des parents. - **Login** : LoginBuilder (challenge, nonce, chiffrement « for all », preuve avec signatures), publication message → signatures → clés. -- **Sync** : SyncService, HashCache (IndexedDB), `markSeenBatch`, déduplication, fetch clés/signatures, vérification hash/signatures/timestamp, mise à jour du graphe. Détection des confirmations de pairing au sync. +- **Sync** : SyncService, HashCache (IndexedDB), `markSeenBatch`, déduplication, fetch clés/signatures, vérification hash/signatures/timestamp, mise à jour du graphe. Détection des confirmations de pairing au sync. **Acceptation** : une version d'objet (MessageAValider) n'est acceptée que si elle est signée par les validateurs conformément aux conditions de l'action de validation ; voir `features/userwallet-acceptation-version-validateurs.md`. **DH** : le DH n'est pas systématique pour tous les types de messages (login sans MsgCle ; pairing optionnel ; sync en base64) ; voir `features/userwallet-dh-systematique-scan-fetch.md`. - **Iframe** : iframeChannel + useChannel ; messages `auth-request`, `auth-response`, `login-proof`, `service-status`, `error` ; postMessage vers parent avec `'*'`. - **Export/import** : identité, relais, pairs, hash_cache, pairing_confirm (IndexedDB). « Supprimer » global avec option export avant suppression. - **ESLint** : config flat, `typescript-eslint`, type-aware ; voir `features/userwallet-eslint-fix.md`. diff --git a/userwallet/features/userwallet-acceptation-version-validateurs.md b/userwallet/features/userwallet-acceptation-version-validateurs.md new file mode 100644 index 0000000..336ad0d --- /dev/null +++ b/userwallet/features/userwallet-acceptation-version-validateurs.md @@ -0,0 +1,34 @@ +# UserWallet – Acceptation d'une version d'objet et validateurs + +**Author:** Équipe 4NK +**Date:** 2026-01-26 + +## Règle + +On ne peut accepter une version d'un objet que si elle est **signée des validateurs conformément aux conditions de signatures de cette action de validation**. + +## Concrètement + +- **Objets concernés** : tout message « à valider » (`MessageAValider`) : Contrat, Champ, Action, Membre. Chacun porte un champ `validateurs` (membres_du_role, signatures_obligatoires). +- **Acceptation** : lors du sync, un message est ajouté au graphe uniquement si : + 1. Hash canonique et timestamp valides ; + 2. Pour un `MessageAValider` : les signatures jointes (fetch par hash) satisfont les validateurs : + - au moins une `cle_publique` dans les `signatures_obligatoires` (sinon on ne peut pas vérifier) ; + - toutes les signatures sont crypto-valides et leurs clés sont autorisées par les validateurs ; + - au moins une signature autorisée (refus si aucune). +- **Refus** : si validateurs présents mais non vérifiables (aucune `cle_publique`), ou signatures manquantes / non autorisées → message **non** ajouté au graphe, compté « non valide ». + +## Implémentation + +- **`utils/validatorsAccept`** : `buildAllowedPubkeysFromValidateurs`, `canVerifyValidateurs`, `validateSignaturesAgainstValidateurs`, `getValidateursIfMessageAValider`, `validateMessageAValiderForSync`. +- **`syncValidate` / `syncUpdateGraph` / `SyncService`** : `validateDecryptedMessage` puis `updateGraphFromMessage` ; pour `MessageAValider`, exige signatures et conformité validateurs ; pour Service / Pair, hash + timestamp + crypto seul si sigs présentes. + +## Limites actuelles + +- **Cardinalité et dépendances** : non appliquées lors du sync (nécessitent résolution pairs / graphe). Seule la conformité « clés autorisées » + au moins une sig est vérifiée. +- **Pairs** : la résolution pair ↔ membre n’est pas utilisée en sync ; seules les `cle_publique` explicites dans `signatures_obligatoires` le sont. + +## Références + +- `userwallet/docs/specs.md` : validateurs, « Contrat invalide (hash/signature/validateurs non satisfaits) », « Interdire l’acceptation d’une signature valide cryptographiquement mais non autorisée contractuellement ». +- `features/userwallet-validation-conformite.md` : validation login, clés autorisées, cardinalité. diff --git a/userwallet/features/userwallet-dh-systematique-scan-fetch.md b/userwallet/features/userwallet-dh-systematique-scan-fetch.md new file mode 100644 index 0000000..d693dba --- /dev/null +++ b/userwallet/features/userwallet-dh-systematique-scan-fetch.md @@ -0,0 +1,78 @@ +# UserWallet – DH systématique et flux scan → fetch par hash → déchiffrer + +**Author:** Équipe 4NK +**Date:** 2026-01-26 + +## Question + +Le DH est-il systématiquement mis en place pour les types de messages envoyés (sauf DH) afin que l’utilisateur **scanne** → **aille chercher le hash** avec le message → qu’il **sache alors déchiffrer** ? + +## Réponse courte + +**Non.** Aujourd’hui le DH n’est pas systématique pour tous les types de messages. Seul le **pairing** utilise ECDH + MsgCle lorsque la clé publique du pair distant est connue ; le **login** et le **sync générique** ne suivent pas ce schéma. + +--- + +## État actuel par type de message + +### 1. Pairing (membre finaliser) + +- **Envoi** : si `PairConfig.publicKey` (pair distant) est défini → chiffrement ECDH, POST `MsgChiffre` + POST `MsgCle` (`df_ecdh_scannable` = clé publique de l’émetteur). Sinon → message en clair (base64), **aucun** `MsgCle`. +- **Réception** : `fetchPairingMessage` (hors sync générique) fetch messages → fetch keys par hash → déchiffrement ECDH si `senderPublicKey` / identité connus, sinon base64. +- **DH systématique ?** Non. DH seulement si clé publique du pair connue ; sinon pas de DH, pas de MsgCle. + +### 2. Login (challenge) + +- **Envoi** : `encryptForAll` (clé symétrique aléatoire), POST `MsgChiffre` + POST signatures. **Aucun** POST `MsgCle`. +- **Réception** : la preuve est envoyée au parent (iframe) via `postMessage`. Le message login sur le relais n’est pas déchiffré par le sync (pas de MsgCle, donc `fetchKeys` vide → `indechiffrable`). +- **DH systématique ?** Non. Pas de DH, pas de MsgCle. Pas de flux « scan → fetch par hash → déchiffrer » pour le login. + +### 3. Sync générique (Service, Contrat, Champ, Action, Membre, Pair…) + +- **Réception** : `tryDecrypt` fait `fetchKeys(hash)` puis, si `keys.length > 0`, **`atob(message_chiffre)`** (base64) uniquement. Les clés et le `df_ecdh_scannable` **ne sont pas utilisés** pour du vrai déchiffrement ECDH. +- **Flux** : on fetch les **messages** (par plage / service), puis les **clés par hash**. Pas de « scan des MsgCle en premier → hashes déchiffrables via ECDH → fetch messages par hash » comme dans les specs. +- **DH systématique ?** Non. Le sync suppose du base64 et n’applique pas un schéma DH systématique. + +--- + +## Ce que prévoient les specs + +- **Message individuel de déchiffrement** : hash + clé de chiffrement + **df** = « diffie-hellman à scanner » pour obtenir la clé de déchiffrement (secret partagé). +- **Flux** : « Récupérer et scanner tous les messages de clés » (depuis date anniversaire / checkpoint) ; les messages déchiffrables sont identifiés **via les DH** ; puis on va **chercher le message par hash** et on déchiffre. +- **Publish to all** : tout est chiffré ; seuls les pairs qui peuvent dériver la clé (via ECDH) peuvent lire. Le « matériel DH » est publiable car inexploitable par les tiers. + +Donc, pour les types de messages **hors DH** : chiffrement + MsgCle avec **df_ecdh_scannable** systématique, et flux **scan des clés → fetch message par hash → déchiffrer**. + +--- + +## Écarts principaux + +| Aspect | Specs | Actuel | +|--------|--------|--------| +| DH pour tous les messages (hors DH) | Oui, via MsgCle + df | Non : login sans MsgCle ; pairing optionnel ; sync en base64 | +| Flux | Scan MsgCle → hashes déchiffrables → fetch message par hash | Fetch messages → fetch keys par hash ; pas de scan MsgCle centré ECDH | +| Utilisation des clés en sync | Déchiffrement ECDH avec df | Base64 si `keys.length > 0` ; clés non utilisées | +| Login | Sous-entendu publish to all + MsgCle/DH si d’autres doivent déchiffrer | `encryptForAll` seul, pas de MsgCle | + +--- + +## Pistes pour alignement + +1. **Login** : si le message login sur le relais doit être déchiffrable (ex. par le service ou un autre client) → publier des `MsgCle` avec **df_ecdh_scannable** pour les destinataires concernés (p.ex. clé publique du service ou des pairs), au lieu de se limiter à `encryptForAll` sans MsgCle. +2. **Pairing** : rendre DH **obligatoire** dès qu’un pair distant existe (exiger `publicKey`), et ne plus envoyer de membre finaliser en clair (base64). +3. **Sync** : + - Implémenter un **scan des MsgCle** (depuis anniversaire / checkpoint), identification des hash déchiffrables via ECDH (quand on a la clé privée correspondante). + - Ensuite **fetch des messages par hash** pour ces hash uniquement. + - Remplacer le « base64 si keys > 0 » par un **vrai déchiffrement ECDH** utilisant `df_ecdh_scannable` et les clés. +4. **Types contrats / graphe** (service, contrat, champ, action, membre, pair) : définir quels types sont publiés par qui, avec quels MsgCle / DH, et appliquer le même flux scan → fetch par hash → déchiffrer pour tous. + +--- + +## Références + +- `userwallet/docs/specs.md` : Message individuel de déchiffrement, df DH à scanner, « récupérer et scanner tous les messages de clés », fenêtre de scan, fetch par hash. +- `userwallet/features/userwallet-pairing-connecte.md` : chiffrement pairing ECDH vs base64, MsgCle. +- `userwallet/src/utils/encryption.ts` : `encryptWithECDH` / `decryptWithECDH`, `encryptForAll` / `decryptForAll`. +- `userwallet/src/services/pairingConfirm.ts` : publication pairing + MsgCle, `fetchPairingMessage`, ECDH. +- `userwallet/src/services/syncService.ts` : `tryDecrypt` (base64), `fetchKeys`. +- `userwallet/src/services/loginBuilder.ts` : `encryptForAll`, pas de MsgCle. diff --git a/userwallet/src/App.tsx b/userwallet/src/App.tsx index deef91e..57f76b6 100644 --- a/userwallet/src/App.tsx +++ b/userwallet/src/App.tsx @@ -14,6 +14,7 @@ import { RelaySettingsScreen } from './components/RelaySettingsScreen'; import { PairManagementScreen } from './components/PairManagementScreen'; import { SyncScreen } from './components/SyncScreen'; import { LoginScreen } from './components/LoginScreen'; +import { LoginSignScreen } from './components/LoginSignScreen'; import { ServiceListScreen } from './components/ServiceListScreen'; import { DataExportImportScreen } from './components/DataExportImportScreen'; import { UnlockScreen } from './components/UnlockScreen'; @@ -37,6 +38,7 @@ function AppContent(): JSX.Element { } /> } /> } /> + } /> } /> } /> } /> diff --git a/userwallet/src/components/LoginCollectShare.tsx b/userwallet/src/components/LoginCollectShare.tsx new file mode 100644 index 0000000..813aee0 --- /dev/null +++ b/userwallet/src/components/LoginCollectShare.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; +import type { LoginProof } from '../types/identity'; + +const QR_SIZE = 200; + +function buildLoginSignUrl(hash: string, nonce: string): string { + const params = new URLSearchParams({ hash, nonce }); + return `${window.location.origin}/login-sign?${params.toString()}`; +} + +interface LoginCollectShareProps { + proof: LoginProof; +} + +/** + * Device 1: show link + QR for "Demander signature sur l'autre appareil" during collect. + */ +export function LoginCollectShare({ proof }: LoginCollectShareProps): JSX.Element { + const [qrDataUrl, setQrDataUrl] = useState(null); + const url = buildLoginSignUrl(proof.challenge.hash, proof.challenge.nonce); + + useEffect(() => { + QRCode.toDataURL(url, { width: QR_SIZE }) + .then(setQrDataUrl) + .catch((err: unknown) => { + console.error('QR login-sign failed:', err); + }); + }, [url]); + + return ( +
+

{`Demander signature sur l${"'"}autre appareil`}

+

Ouvrez ce lien ou scannez le QR sur le 2ᵉ appareil.

+

+ + {url} + +

+ {qrDataUrl !== null && ( + QR code : lien pour signer le login sur le 2e appareil + )} +
+ ); +} diff --git a/userwallet/src/components/LoginScreen.tsx b/userwallet/src/components/LoginScreen.tsx index 5344347..8127506 100644 --- a/userwallet/src/components/LoginScreen.tsx +++ b/userwallet/src/components/LoginScreen.tsx @@ -4,18 +4,33 @@ import { useIdentity } from '../hooks/useIdentity'; import { useErrorHandler } from '../hooks/useErrorHandler'; import { useLoginStateMachine } from '../hooks/useLoginStateMachine'; import { ErrorDisplay } from './ErrorDisplay'; +import { LoginCollectShare } from './LoginCollectShare'; import { getStoredRelays } from '../utils/relay'; import { GraphResolver } from '../services/graphResolver'; import { LoginBuilder } from '../services/loginBuilder'; -import { postMessageChiffre, postSignature } from '../utils/relay'; import { useChannel } from '../hooks/useChannel'; +import { isPairingSatisfied, getPairsForMember } from '../utils/pairing'; +import { + buildAllowedPubkeys, + checkDependenciesSatisfied, + hasRemoteSignatures, +} from '../utils/loginValidation'; +import { publishMessageAndSigs } from '../utils/loginPublish'; +import { + buildPairToMembers, + buildPubkeyToPair, + runCollectLoop, + COLLECT_POLL_MS, + COLLECT_TIMEOUT_MS, + type ProofSignature, +} from '../utils/collectSignatures'; import { verifyTimestamp, verifyMessageSignaturesStrict, } from '../utils/verification'; import * as nonceStore from '../utils/nonceStore'; import type { LoginPath, LoginProof } from '../types/identity'; -import type { MessageBase, MsgSignature } from '../types/message'; +import type { MessageBase } from '../types/message'; export function LoginScreen(): JSX.Element { const navigate = useNavigate(); @@ -30,6 +45,15 @@ export function LoginScreen(): JSX.Element { const [proof, setProof] = useState(null); const [isBuilding, setIsBuilding] = useState(false); const [isPublishing, setIsPublishing] = useState(false); + const [isCollecting, setIsCollecting] = useState(false); + const [awaitingRemoteAccept, setAwaitingRemoteAccept] = useState(false); + const [collectedMerged, setCollectedMerged] = useState( + null, + ); + const [collectedPublishStats, setCollectedPublishStats] = useState<{ + successCount: number; + relaysCount: number; + } | null>(null); const graphResolver = new GraphResolver(); @@ -102,6 +126,18 @@ export function LoginScreen(): JSX.Element { loginPath.membre_uuid, ); + const localPairs = getPairsForMember(loginPath.membre_uuid).filter( + (p) => + p.is_local && + loginPath.pairs_attendus.includes(p.uuid), + ); + + const pk = identity.privateKey; + if (pk === undefined) { + handleError('Clé privée indisponible. Déverrouillez l\'identité.', 'IDENTITY_LOCKED'); + return; + } + const signatures: Array<{ signature: string; cle_publique: string; @@ -109,31 +145,21 @@ export function LoginScreen(): JSX.Element { pair_uuid: string; }> = []; - const pk = identity.privateKey; - if (pk === undefined) { - handleError('Clé privée indisponible. Déverrouillez l\'identité.', 'IDENTITY_LOCKED'); - return; - } - for (const req of loginPath.signatures_requises) { - if (req.pair_uuid === undefined) { - handleError('Pair UUID manquant pour une signature requise', 'MISSING_PAIR_UUID'); - continue; - } - const sig = loginBuilder.signChallenge( - challenge, - req.pair_uuid, - pk, - ); + for (const pair of localPairs) { + const sig = loginBuilder.signChallenge(challenge, pair.uuid, pk); signatures.push({ signature: sig, cle_publique: identity.publicKey, nonce: challenge.nonce, - pair_uuid: req.pair_uuid, + pair_uuid: pair.uuid, }); } if (signatures.length === 0) { - handleError('Aucune signature générée', 'NO_SIGNATURES'); + handleError( + 'Aucun pair local pour ce membre. Signez sur ce device ou ajoutez un pair.', + 'NO_LOCAL_PAIRS', + ); return; } @@ -168,38 +194,6 @@ export function LoginScreen(): JSX.Element { return; } - if (loginPath !== null) { - const allowedPubkeys = new Set(); - for (const req of loginPath.signatures_requises) { - if (req.cle_publique !== undefined) { - allowedPubkeys.add(req.cle_publique); - } - } - if (allowedPubkeys.size > 0) { - const minimalMsg = { - hash: { hash_value: proof.challenge.hash }, - } as unknown as MessageBase; - const sigs = proof.signatures.map((s) => ({ - hash: proof.challenge.hash, - cle_publique: s.cle_publique, - signature: s.signature, - nonce: s.nonce, - })); - const { valid, unauthorized } = verifyMessageSignaturesStrict( - minimalMsg, - sigs, - allowedPubkeys, - ); - if (unauthorized.length > 0 || valid.length === 0) { - handleError( - 'Signature(s) avec clé non autorisée par les validateurs (X_PUBKEY_NOT_AUTHORIZED)', - 'X_PUBKEY_NOT_AUTHORIZED', - ); - return; - } - } - } - setIsPublishing(true); clearError(); try { @@ -212,41 +206,92 @@ export function LoginScreen(): JSX.Element { const loginBuilder = new LoginBuilder(identity, relays.map((r) => r.endpoint)); const msgChiffre = loginBuilder.challengeToMsgChiffre(proof.challenge); - const publishResults: Array<{ relay: string; success: boolean }> = []; - - for (const relay of relays) { - try { - await postMessageChiffre(relay.endpoint, msgChiffre); - - for (const sig of proof.signatures) { - const msgSig: MsgSignature = { - signature: { - hash: proof.challenge.hash, - cle_publique: sig.cle_publique, - signature: sig.signature, - nonce: sig.nonce, - }, - hash_cible: proof.challenge.hash, - }; - await postSignature(relay.endpoint, msgSig); - } - - publishResults.push({ relay: relay.endpoint, success: true }); - } catch (err) { - handleError(err, `Erreur lors de la publication sur ${relay.endpoint}`); - publishResults.push({ relay: relay.endpoint, success: false }); - } - } - - const successCount = publishResults.filter((r) => r.success).length; + const successCount = await publishMessageAndSigs( + relays, + msgChiffre, + proof.signatures, + ); if (successCount === 0) { handleError('Échec de la publication sur tous les relais', 'PUBLISH_FAILED'); return; } + let merged = proof.signatures; + if (loginPath !== null) { + setIsCollecting(true); + try { + const pairToMembers = buildPairToMembers(loginPath.pairs_attendus); + const pubkeyToPair = buildPubkeyToPair( + identity.publicKey, + loginPath.pairs_attendus, + ); + const endpoints = relays.map((r) => r.endpoint); + merged = await runCollectLoop( + endpoints, + proof.challenge.hash, + proof.signatures, + loginPath, + pairToMembers, + pubkeyToPair, + { pollMs: COLLECT_POLL_MS, timeoutMs: COLLECT_TIMEOUT_MS }, + ); + } finally { + setIsCollecting(false); + } + } + + if ( + loginPath !== null && + hasRemoteSignatures(merged, loginPath.pairs_attendus) + ) { + setCollectedMerged(merged); + setCollectedPublishStats({ + successCount, + relaysCount: relays.length, + }); + setAwaitingRemoteAccept(true); + setIsPublishing(false); + return; + } + + const finalProof = await loginBuilder.buildProof(proof.challenge, merged); + if (loginPath !== null) { + if (!checkDependenciesSatisfied(loginPath, finalProof)) { + handleError( + 'Dépendances entre signatures non satisfaites (membres requis manquants)', + 'DEPENDENCIES_UNSATISFIED', + ); + return; + } + const allowedPubkeys = buildAllowedPubkeys(loginPath, identity.publicKey); + if (allowedPubkeys.size > 0) { + const minimalMsg = { + hash: { hash_value: proof.challenge.hash }, + } as unknown as MessageBase; + const sigs = merged.map((s) => ({ + hash: proof.challenge.hash, + cle_publique: s.cle_publique, + signature: s.signature, + nonce: s.nonce, + })); + const { valid, unauthorized } = verifyMessageSignaturesStrict( + minimalMsg, + sigs, + allowedPubkeys, + ); + if (unauthorized.length > 0 || valid.length === 0) { + handleError( + 'Signature(s) avec clé non autorisée par les validateurs (X_PUBKEY_NOT_AUTHORIZED)', + 'X_PUBKEY_NOT_AUTHORIZED', + ); + return; + } + } + } + await nonceStore.markUsed(proof.challenge.nonce, proof.challenge.timestamp); - const updatedProof = { ...proof, statut: 'publie' as const }; + const updatedProof = { ...finalProof, statut: 'publie' as const }; setProof(updatedProof); sendLoginProof(updatedProof); @@ -271,7 +316,29 @@ export function LoginScreen(): JSX.Element { return (

Identité requise pour se connecter

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

Se connecter

+

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

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

Se connecter

@@ -318,7 +481,11 @@ export function LoginScreen(): JSX.Element { /> - @@ -350,13 +517,37 @@ export function LoginScreen(): JSX.Element { Signatures requises: {loginPath.signatures_requises.length}

{loginPath.statut === 'complet' && ( - )} + {loginPath.statut === 'incomplet' && showRecoveryActions && ( +
+ + +
+ )} )} + {showRecoveryActions && loginPath === null && ( +
+ + +
+ )} {proof !== null && (

Preuve de login

@@ -370,16 +561,66 @@ export function LoginScreen(): JSX.Element {

Signatures: {proof.signatures.length}

- {proof.statut === 'en_attente' && ( - )} + {isCollecting && proof !== null && ( + + )} + {awaitingRemoteAccept && + collectedMerged !== null && + proof !== null && + loginPath !== null && ( +
+

+ Confirmer les signatures du 2ᵉ appareil +

+

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

+
+ + +
+
+ )}
)}
- +
); diff --git a/userwallet/src/components/LoginSignScreen.tsx b/userwallet/src/components/LoginSignScreen.tsx new file mode 100644 index 0000000..dd00575 --- /dev/null +++ b/userwallet/src/components/LoginSignScreen.tsx @@ -0,0 +1,73 @@ +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useIdentity } from '../hooks/useIdentity'; +import { useSignAndPostLogin } from '../hooks/useSignAndPostLogin'; + +function LoginSignForm(props: { + status: 'idle' | 'signing' | 'done' | 'error'; + error: string | null; + onBack: () => void; +}): JSX.Element { + const { status, error, onBack } = props; + return ( +
+

Signer le login

+ {error !== null &&

{error}

} + {status === 'signing' &&

Signature et publication en cours…

} + {status === 'done' && ( +

Signature publiée. Retournez sur le 1ᵉʳ appareil.

+ )} + {(status === 'done' || status === 'error') && ( + + )} +
+ ); +} + +function LoginSignError(props: { + title: string; + message: string; + onBack: () => void; +}): JSX.Element { + const { title, message, onBack } = props; + return ( +
+

{title}

+

{message}

+ +
+ ); +} + +/** + * Device 2: sign login challenge (hash, nonce) and post to relays. + * Route: /login-sign?hash=...&nonce=... + */ +export function LoginSignScreen(): JSX.Element { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { identity } = useIdentity(); + const hash = searchParams.get('hash') ?? ''; + const nonce = searchParams.get('nonce') ?? ''; + const { status, error } = useSignAndPostLogin(hash, nonce, identity); + const goBack = (): void => navigate('/'); + + if (hash === '' || nonce === '') { + return ( + + ); + } + if (identity === null) { + return ( + + ); + } + return ; +} diff --git a/userwallet/src/hooks/useSignAndPostLogin.ts b/userwallet/src/hooks/useSignAndPostLogin.ts new file mode 100644 index 0000000..13be6f5 --- /dev/null +++ b/userwallet/src/hooks/useSignAndPostLogin.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; +import { signAndPostLoginChallenge } from '../utils/loginSign'; +import type { LocalIdentity } from '../types/identity'; + +type Status = 'idle' | 'signing' | 'done' | 'error'; + +export function useSignAndPostLogin( + hash: string, + nonce: string, + identity: LocalIdentity | null, +): { status: Status; error: string | null } { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + + useEffect(() => { + if (hash === '' || nonce === '' || identity === null || status !== 'idle') { + return; + } + const pk = identity.privateKey; + if (pk === undefined) { + setError(`Déverrouillez l${"'"}identité pour signer.`); + setStatus('error'); + return; + } + setStatus('signing'); + signAndPostLoginChallenge(hash, nonce, pk, identity.publicKey) + .then((ok) => { + setStatus(ok === 0 ? 'error' : 'done'); + if (ok === 0) { + setError('Échec publication sur tous les relais.'); + } + }) + .catch((err) => { + console.error('signAndPostLoginChallenge failed:', err); + setError('Erreur lors de la signature.'); + setStatus('error'); + }); + }, [hash, nonce, identity, status]); + + return { status, error }; +} diff --git a/userwallet/src/services/graphResolver.ts b/userwallet/src/services/graphResolver.ts index 9b0101f..e5f5f35 100644 --- a/userwallet/src/services/graphResolver.ts +++ b/userwallet/src/services/graphResolver.ts @@ -242,6 +242,7 @@ export class GraphResolver { pair_uuid: undefined, cle_publique: sigReq.cle_publique, cardinalite_minimale: sigReq.cardinalite_minimale, + dependances: sigReq.dependances, }); } } diff --git a/userwallet/src/services/loginBuilder.ts b/userwallet/src/services/loginBuilder.ts index 2376309..8030531 100644 --- a/userwallet/src/services/loginBuilder.ts +++ b/userwallet/src/services/loginBuilder.ts @@ -72,13 +72,14 @@ export class LoginBuilder { /** * Sign a challenge with a pair's private key. + * Signs hash-nonce only (verification and relay fetch use same format; pair_uuid is metadata). */ signChallenge( challenge: LoginChallenge, - pairUuid: string, + _pairUuid: string, pairPrivateKey: string, ): string { - const messageToSign = `${challenge.hash}-${challenge.nonce}-${pairUuid}`; + const messageToSign = `${challenge.hash}-${challenge.nonce}`; return signMessage(messageToSign, pairPrivateKey); } diff --git a/userwallet/src/services/loginStateMachine.ts b/userwallet/src/services/loginStateMachine.ts index 428ce02..ab865d9 100644 --- a/userwallet/src/services/loginStateMachine.ts +++ b/userwallet/src/services/loginStateMachine.ts @@ -137,6 +137,9 @@ export function transition( errorCode: 'E_PUBLISH_LOGIN_PARTIAL', }; } + if (event.type === 'E_LOCAL_VERDICT_REJECT') { + return { nextState: 'S_LOGIN_FAILURE' }; + } if (event.type === 'E_BACK') { return { nextState: 'S_LOGIN_COLLECT_SIGNATURES' }; } @@ -165,6 +168,9 @@ export function transition( if (event.type === 'E_SYNC_NOW') { return { nextState: 'S_LOGIN_BUILD_PATH' }; } + if (event.type === 'E_ADD_PAIR') { + return { nextState: 'S_LOGIN_SELECT_SERVICE' }; + } if (event.type === 'E_BACK') { return { nextState: 'S_LOGIN_SELECT_SERVICE' }; } diff --git a/userwallet/src/services/syncService.ts b/userwallet/src/services/syncService.ts index d1180a2..f358c03 100644 --- a/userwallet/src/services/syncService.ts +++ b/userwallet/src/services/syncService.ts @@ -5,20 +5,12 @@ import { } from '../utils/relay'; import { fetchAndLoadBloom } from '../utils/bloom'; import type { RelayConfig } from '../types/identity'; -import type { MessageBase, MsgChiffre, MsgSignature, MsgCle } from '../types/message'; +import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message'; import { GraphResolver } from './graphResolver'; -import { verifyMessageHash, verifyMessageSignatures, verifyTimestamp } from '../utils/verification'; +import { validateDecryptedMessage } from './syncValidate'; +import { updateGraphFromMessage } from './syncUpdateGraph'; import { HashCache } from '../utils/cache'; import { runSyncLoop, type SyncOneRelayResult } from './syncLoop'; -import { isContractVersionSupported } from '../utils/contractVersion'; -import type { - Service, - Contrat, - Champ, - Action, - Membre, - Pair, -} from '../types/contract'; /** * Service for synchronizing messages from relays. @@ -73,9 +65,12 @@ export class SyncService { if (decrypted === null) { return 'indechiffrable'; } - const valid = await this.validateMessage(decrypted); + const valid = await validateDecryptedMessage( + decrypted, + (h) => this.fetchSignatures(h), + ); if (valid) { - this.updateGraph(decrypted); + updateGraphFromMessage(decrypted, this.graphResolver); return 'validated'; } return 'nonValide'; @@ -266,69 +261,4 @@ export class SyncService { return allSignatures; } - /** - * Validate a decrypted message (check hash, signatures, timestamp, etc.). - */ - private async validateMessage(decrypted: unknown): Promise { - try { - const msg = decrypted as MessageBase; - if (msg.hash === undefined) { - return false; - } - - const hashValid = await verifyMessageHash(msg); - if (!hashValid) { - return false; - } - - if (!verifyTimestamp(msg.timestamp)) { - return false; - } - - const signatures = await this.fetchSignatures(msg.hash.hash_value); - if (signatures.length > 0) { - const { valid } = verifyMessageSignatures(msg, signatures.map((s) => s.signature)); - if (valid.length === 0) { - return false; - } - } - - return true; - } catch { - return false; - } - } - - /** - * Update graph cache with decrypted message. - */ - private updateGraph(decrypted: unknown): void { - try { - const msg = decrypted as MessageBase; - const typeNames = msg.types.types_names_chiffres.toLowerCase(); - - if (typeNames.includes('service')) { - this.graphResolver.addService(msg as unknown as Service); - } else if (typeNames.includes('contrat')) { - const c = msg as unknown as Contrat; - if (!isContractVersionSupported(c.version)) { - console.warn( - `[SyncService] Contrat ${c.uuid} version ${c.version} not supported, skipped`, - ); - return; - } - this.graphResolver.addContrat(c); - } else if (typeNames.includes('champ')) { - this.graphResolver.addChamp(msg as unknown as Champ); - } else if (typeNames.includes('action')) { - this.graphResolver.addAction(msg as unknown as Action); - } else if (typeNames.includes('membre')) { - this.graphResolver.addMembre(msg as unknown as Membre); - } else if (typeNames.includes('pair')) { - this.graphResolver.addPair(msg as unknown as Pair); - } - } catch (error) { - console.error('Error updating graph:', error); - } - } } diff --git a/userwallet/src/services/syncUpdateGraph.ts b/userwallet/src/services/syncUpdateGraph.ts new file mode 100644 index 0000000..6158570 --- /dev/null +++ b/userwallet/src/services/syncUpdateGraph.ts @@ -0,0 +1,47 @@ +import { isContractVersionSupported } from '../utils/contractVersion'; +import type { MessageBase } from '../types/message'; +import type { GraphResolver } from './graphResolver'; +import type { + Service, + Contrat, + Champ, + Action, + Membre, + Pair, +} from '../types/contract'; + +/** + * Update graph cache with decrypted message. + */ +export function updateGraphFromMessage( + decrypted: unknown, + graphResolver: GraphResolver, +): void { + try { + const msg = decrypted as MessageBase; + const typeNames = msg.types.types_names_chiffres.toLowerCase(); + + if (typeNames.includes('service')) { + graphResolver.addService(msg as unknown as Service); + } else if (typeNames.includes('contrat')) { + const c = msg as unknown as Contrat; + if (!isContractVersionSupported(c.version)) { + console.warn( + `[SyncService] Contrat ${c.uuid} version ${c.version} not supported, skipped`, + ); + return; + } + graphResolver.addContrat(c); + } else if (typeNames.includes('champ')) { + graphResolver.addChamp(msg as unknown as Champ); + } else if (typeNames.includes('action')) { + graphResolver.addAction(msg as unknown as Action); + } else if (typeNames.includes('membre')) { + graphResolver.addMembre(msg as unknown as Membre); + } else if (typeNames.includes('pair')) { + graphResolver.addPair(msg as unknown as Pair); + } + } catch (error) { + console.error('Error updating graph:', error); + } +} diff --git a/userwallet/src/services/syncValidate.ts b/userwallet/src/services/syncValidate.ts new file mode 100644 index 0000000..96042ae --- /dev/null +++ b/userwallet/src/services/syncValidate.ts @@ -0,0 +1,44 @@ +import { verifyMessageHash, verifyMessageSignatures, verifyTimestamp } from '../utils/verification'; +import { + getValidateursIfMessageAValider, + validateMessageAValiderForSync, +} from '../utils/validatorsAccept'; +import type { MessageBase, MsgSignature } from '../types/message'; + +/** + * Validate a decrypted message (hash, timestamp, signatures). + * MessageAValider: accept only if signed by validators per validateurs; + * otherwise crypto-only check when signatures present. + */ +export async function validateDecryptedMessage( + decrypted: unknown, + fetchSignatures: (hash: string) => Promise, +): Promise { + try { + const msg = decrypted as MessageBase; + if (msg.hash === undefined) { + return false; + } + if (!(await verifyMessageHash(msg)) || !verifyTimestamp(msg.timestamp)) { + return false; + } + const signatures = await fetchSignatures(msg.hash.hash_value); + const sigList = signatures.map((s) => s.signature); + const validateurs = getValidateursIfMessageAValider(msg); + if (validateurs !== null) { + return validateMessageAValiderForSync( + msg, + sigList, + validateurs, + '[SyncValidate]', + ); + } + if (sigList.length > 0) { + const { valid } = verifyMessageSignatures(msg, sigList); + return valid.length > 0; + } + return true; + } catch { + return false; + } +} diff --git a/userwallet/src/types/identity.ts b/userwallet/src/types/identity.ts index 2343ec4..981c18c 100644 --- a/userwallet/src/types/identity.ts +++ b/userwallet/src/types/identity.ts @@ -64,6 +64,8 @@ export interface SignatureRequirement { pair_uuid?: string; cle_publique?: string; cardinalite_minimale?: number; + /** UUIDs of other requirements that must be satisfied first. */ + dependances?: string[]; } /** diff --git a/userwallet/src/utils/collectSignatures.ts b/userwallet/src/utils/collectSignatures.ts new file mode 100644 index 0000000..fa7fc14 --- /dev/null +++ b/userwallet/src/utils/collectSignatures.ts @@ -0,0 +1,165 @@ +import { getSignatures } from './relay'; +import { getStoredPairs } from './pairing'; +import { hasEnoughSignatures } from './loginValidation'; +import type { LoginPath } from '../types/identity'; +import type { MsgSignature } from '../types/message'; + +export interface ProofSignature { + signature: string; + cle_publique: string; + nonce: string; + pair_uuid: string; +} + +/** + * Fetch signatures for hash from all relays, aggregate and deduplicate. + */ +export async function fetchSignaturesForHash( + relayEndpoints: string[], + hash: string, +): Promise { + const seen = new Set(); + const out: MsgSignature[] = []; + for (const ep of relayEndpoints) { + try { + const list = await getSignatures(ep, hash); + for (const m of list) { + const s = m.signature; + const key = `${s.cle_publique}\t${s.nonce}\t${s.signature}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(m); + } + } catch { + continue; + } + } + return out; +} + +/** + * Map pair_uuid -> membres_parents_uuid for pairs in pairsAttendus. + */ +export function buildPairToMembers( + pairsAttendus: string[], +): Map { + const pairs = getStoredPairs(); + const set = new Set(pairsAttendus); + const m = new Map(); + for (const p of pairs) { + if (!set.has(p.uuid)) { + continue; + } + m.set(p.uuid, p.membres_parents_uuid); + } + return m; +} + +/** + * Map cle_publique -> pair_uuid for local (identity) and remote (pair.publicKey) pairs. + */ +export function buildPubkeyToPair( + identityPublicKey: string, + pairsAttendus: string[], +): Map { + const pairs = getStoredPairs(); + const set = new Set(pairsAttendus); + const m = new Map(); + for (const p of pairs) { + if (!set.has(p.uuid)) { + continue; + } + if (p.is_local) { + m.set(identityPublicKey, p.uuid); + } else if (p.publicKey !== undefined) { + m.set(p.publicKey, p.uuid); + } + } + return m; +} + +/** + * Map MsgSignature[] to ProofSignature[] using pubkey->pair. Skip unknown pubkeys. + */ +export function mapMsgSignaturesToProofFormat( + msgs: MsgSignature[], + hash: string, + pubkeyToPair: Map, +): ProofSignature[] { + const out: ProofSignature[] = []; + for (const m of msgs) { + const s = m.signature; + if (s.hash !== hash) { + continue; + } + const pairUuid = pubkeyToPair.get(s.cle_publique); + if (pairUuid === undefined) { + continue; + } + out.push({ + signature: s.signature, + cle_publique: s.cle_publique, + nonce: s.nonce, + pair_uuid: pairUuid, + }); + } + return out; +} + +export const COLLECT_POLL_MS = 2000; +export const COLLECT_TIMEOUT_MS = 300000; + +export function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Merge our sigs with fetched, dedup by pair_uuid (keep first). + */ +function mergeDedupByPair( + ours: ProofSignature[], + fetched: ProofSignature[], +): ProofSignature[] { + const byPair = new Map(); + for (const s of ours) { + byPair.set(s.pair_uuid, s); + } + for (const s of fetched) { + if (!byPair.has(s.pair_uuid)) { + byPair.set(s.pair_uuid, s); + } + } + return Array.from(byPair.values()); +} + +/** + * Collect signatures from relays until we have enough per member, or timeout. + */ +export async function runCollectLoop( + relayEndpoints: string[], + hash: string, + ourSigs: ProofSignature[], + path: LoginPath, + pairToMembers: Map, + pubkeyToPair: Map, + opts: { pollMs: number; timeoutMs: number }, +): Promise { + const start = Date.now(); + let merged = ourSigs; + for (;;) { + if (hasEnoughSignatures(path, merged, pairToMembers)) { + return merged; + } + if (Date.now() - start >= opts.timeoutMs) { + throw new Error('Collecte distante : timeout (signatures manquantes)'); + } + const msgs = await fetchSignaturesForHash(relayEndpoints, hash); + const fetched = mapMsgSignaturesToProofFormat(msgs, hash, pubkeyToPair); + merged = mergeDedupByPair(ourSigs, fetched); + await delay(opts.pollMs); + } +} diff --git a/userwallet/src/utils/loginPublish.ts b/userwallet/src/utils/loginPublish.ts new file mode 100644 index 0000000..6677b8e --- /dev/null +++ b/userwallet/src/utils/loginPublish.ts @@ -0,0 +1,39 @@ +import { postMessageChiffre, postSignature } from './relay'; +import type { RelayConfig } from '../types/identity'; +import type { MsgChiffre, MsgSignature } from '../types/message'; +import type { ProofSignature } from './collectSignatures'; + +/** + * Publish message + ourSigs to relays (best effort per relay). + */ +export async function publishMessageAndSigs( + relays: RelayConfig[], + msgChiffre: MsgChiffre, + ourSigs: ProofSignature[], +): Promise { + let ok = 0; + for (const r of relays) { + if (!r.enabled) { + continue; + } + try { + await postMessageChiffre(r.endpoint, msgChiffre); + for (const sig of ourSigs) { + const msgSig: MsgSignature = { + signature: { + hash: msgChiffre.hash, + cle_publique: sig.cle_publique, + signature: sig.signature, + nonce: sig.nonce, + }, + hash_cible: msgChiffre.hash, + }; + await postSignature(r.endpoint, msgSig); + } + ok++; + } catch (err) { + console.error(`Publish to ${r.endpoint} failed:`, err); + } + } + return ok; +} diff --git a/userwallet/src/utils/loginSign.ts b/userwallet/src/utils/loginSign.ts new file mode 100644 index 0000000..a68b0f0 --- /dev/null +++ b/userwallet/src/utils/loginSign.ts @@ -0,0 +1,33 @@ +import { getStoredRelays } from './relay'; +import { postSignature } from './relay'; +import { signMessage } from './crypto'; +import type { MsgSignature } from '../types/message'; + +/** + * Sign hash-nonce with privateKey and post MsgSignature to all enabled relays. + * Returns number of relays that accepted. + */ +export async function signAndPostLoginChallenge( + hash: string, + nonce: string, + privateKey: string, + publicKey: string, +): Promise { + const messageToSign = `${hash}-${nonce}`; + const signature = signMessage(messageToSign, privateKey); + const msgSig: MsgSignature = { + signature: { hash, cle_publique: publicKey, signature, nonce }, + hash_cible: hash, + }; + const relays = getStoredRelays().filter((r) => r.enabled); + let ok = 0; + for (const r of relays) { + try { + await postSignature(r.endpoint, msgSig); + ok++; + } catch (err) { + console.error(`Post signature to ${r.endpoint} failed:`, err); + } + } + return ok; +} diff --git a/userwallet/src/utils/loginValidation.ts b/userwallet/src/utils/loginValidation.ts new file mode 100644 index 0000000..f671a9e --- /dev/null +++ b/userwallet/src/utils/loginValidation.ts @@ -0,0 +1,151 @@ +import { getStoredPairs } from './pairing'; +import type { LoginPath, SignatureRequirement } from '../types/identity'; +import type { LoginProof } from '../types/identity'; + +/** + * Build allowed pubkeys for strict verification: from requirements (cle_publique) + * and from pairs (pair.publicKey, or identityKey for local pair). + */ +export function buildAllowedPubkeys( + loginPath: LoginPath, + identityPublicKey: string, +): Set { + const out = new Set(); + const pairs = getStoredPairs(); + const pairByUuid = new Map(pairs.map((p) => [p.uuid, p])); + const pairsSet = new Set(loginPath.pairs_attendus); + + for (const req of loginPath.signatures_requises) { + if (req.cle_publique !== undefined) { + out.add(req.cle_publique); + } + if (req.pair_uuid === undefined) { + continue; + } + const pair = pairByUuid.get(req.pair_uuid); + if (pair === undefined || !pairsSet.has(req.pair_uuid)) { + continue; + } + if (pair.is_local) { + out.add(identityPublicKey); + } else if (pair.publicKey !== undefined) { + out.add(pair.publicKey); + } + } + return out; +} + +/** + * Required signature count per member (max of cardinalite_minimale over requirements). + */ +export function requiredSigsPerMember( + requirements: SignatureRequirement[], +): Map { + const m = new Map(); + for (const r of requirements) { + const c = r.cardinalite_minimale ?? 1; + const cur = m.get(r.membre_uuid) ?? 0; + m.set(r.membre_uuid, Math.max(cur, c)); + } + return m; +} + +/** + * Check we have enough distinct pairs per member (for collecte distante). + * Count distinct pair_uuids per member via pairToMembers. + */ +export function hasEnoughSignatures( + path: LoginPath, + signatures: Array<{ pair_uuid: string }>, + pairToMembers: Map, +): boolean { + const required = requiredSigsPerMember(path.signatures_requises); + const pairsPerMember = new Map>(); + for (const sig of signatures) { + const members = pairToMembers.get(sig.pair_uuid); + if (members === undefined) { + continue; + } + for (const memb of members) { + let set = pairsPerMember.get(memb); + if (set === undefined) { + set = new Set(); + pairsPerMember.set(memb, set); + } + set.add(sig.pair_uuid); + } + } + for (const [member, need] of required) { + const have = pairsPerMember.get(member)?.size ?? 0; + if (have < need) { + return false; + } + } + return true; +} + +/** + * True if any signature comes from a non-local pair (2nd device). + * Used to require manual accept before validating when words may have been intercepted. + */ +export function hasRemoteSignatures( + signatures: Array<{ pair_uuid: string }>, + pairsAttendus: string[], +): boolean { + const pairs = getStoredPairs(); + const set = new Set(pairsAttendus); + const pairByUuid = new Map(pairs.map((p) => [p.uuid, p])); + for (const sig of signatures) { + if (!set.has(sig.pair_uuid)) { + continue; + } + const pair = pairByUuid.get(sig.pair_uuid); + if (pair !== undefined && !pair.is_local) { + return true; + } + } + return false; +} + +/** + * Members that have at least one signature in proof (via pair → membres_parents_uuid). + */ +function membersWithSignatures(proof: LoginProof): Set { + const pairs = getStoredPairs(); + const pairByUuid = new Map(pairs.map((p) => [p.uuid, p])); + const out = new Set(); + for (const sig of proof.signatures) { + const pair = pairByUuid.get(sig.pair_uuid); + if (pair === undefined) { + continue; + } + for (const m of pair.membres_parents_uuid) { + out.add(m); + } + } + return out; +} + +/** + * Check that dependencies between requirements are satisfied. + * For each requirement with dependances (member UUIDs), those members must have + * at least one signature in the proof (via a pair of that member). + */ +export function checkDependenciesSatisfied( + path: LoginPath, + proof: LoginProof, +): boolean { + const membersSigned = membersWithSignatures(proof); + for (const req of path.signatures_requises) { + const deps = req.dependances; + if (deps === undefined || deps.length === 0) { + continue; + } + for (const dep of deps) { + if (!membersSigned.has(dep)) { + return false; + } + } + } + return true; +} diff --git a/userwallet/src/utils/relay.ts b/userwallet/src/utils/relay.ts index 695fe68..6f25754 100644 --- a/userwallet/src/utils/relay.ts +++ b/userwallet/src/utils/relay.ts @@ -1,6 +1,9 @@ import type { RelayConfig } from '../types/identity'; import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message'; +/** Timeout for relay fetch (GET/POST). X_RELAY_TIMEOUT when exceeded. */ +export const RELAY_FETCH_TIMEOUT_MS = 15000; + /** * Get stored relay configurations. */ @@ -56,7 +59,9 @@ export async function getMessagesChiffres( if (serviceUuid !== undefined) { params.append('service', serviceUuid); } - const response = await fetch(`${relay}/messages?${params.toString()}`); + const response = await fetch(`${relay}/messages?${params.toString()}`, { + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new Error( `Relay GET /messages failed: ${response.status} ${response.statusText} (${relay})`, @@ -70,7 +75,9 @@ export async function getMessagesChiffres( * Throws on fetch failure or non-ok response. */ export async function getSignatures(relay: string, hash: string): Promise { - const response = await fetch(`${relay}/signatures/${hash}`); + const response = await fetch(`${relay}/signatures/${hash}`, { + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new Error( `Relay GET /signatures/${hash} failed: ${response.status} ${response.statusText} (${relay})`, @@ -84,7 +91,9 @@ export async function getSignatures(relay: string, hash: string): Promise { - const response = await fetch(`${relay}/keys/${hash}`); + const response = await fetch(`${relay}/keys/${hash}`, { + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new Error( `Relay GET /keys/${hash} failed: ${response.status} ${response.statusText} (${relay})`, @@ -102,6 +111,7 @@ export async function postMessageChiffre(relay: string, message: MsgChiffre): Pr method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message), + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), }); if (!response.ok) { throw new Error( @@ -119,6 +129,7 @@ export async function postSignature(relay: string, signature: MsgSignature): Pro method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signature), + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), }); if (!response.ok) { throw new Error( @@ -136,6 +147,7 @@ export async function postKey(relay: string, key: MsgCle): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(key), + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), }); if (!response.ok) { throw new Error( @@ -150,7 +162,9 @@ export async function postKey(relay: string, key: MsgCle): Promise { * Throws on fetch failure or non-ok response. */ export async function getBloom(relay: string): Promise { - const response = await fetch(`${relay}/bloom`); + const response = await fetch(`${relay}/bloom`, { + signal: AbortSignal.timeout(RELAY_FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new Error( `Relay GET /bloom failed: ${response.status} ${response.statusText} (${relay})`, diff --git a/userwallet/src/utils/validatorsAccept.ts b/userwallet/src/utils/validatorsAccept.ts new file mode 100644 index 0000000..18d9b88 --- /dev/null +++ b/userwallet/src/utils/validatorsAccept.ts @@ -0,0 +1,124 @@ +import { + verifyMessageSignaturesStrict, +} from './verification'; +import type { MessageBase, Signature, Validateurs } from '../types/message'; + +function isValidateursShape(v: unknown): v is Validateurs { + return ( + typeof v === 'object' && + v !== null && + 'membres_du_role' in v && + Array.isArray((v as Validateurs).membres_du_role) + ); +} + +/** + * Return validateurs if message is MessageAValider, else null. + */ +export function getValidateursIfMessageAValider( + msg: MessageBase, +): Validateurs | null { + const v = (msg as { validateurs?: unknown }).validateurs; + return isValidateursShape(v) ? v : null; +} + +/** + * Build allowed pubkeys from validateurs (cle_publique in signatures_obligatoires). + * Used when accepting a version of an object during sync; no pair resolution. + */ +export function buildAllowedPubkeysFromValidateurs( + validateurs: Validateurs, +): Set { + const out = new Set(); + for (const m of validateurs.membres_du_role) { + for (const r of m.signatures_obligatoires) { + if (r.cle_publique !== undefined) { + out.add(r.cle_publique); + } + } + } + return out; +} + +/** + * Check if we can verify validators from validateurs (at least one cle_publique). + */ +export function canVerifyValidateurs(validateurs: Validateurs): boolean { + return buildAllowedPubkeysFromValidateurs(validateurs).size > 0; +} + +export interface ValidateSignaturesResult { + accept: boolean; + reason?: string; +} + +/** + * Verify that signatures satisfy validateurs (allowed pubkeys, no unauthorized). + * Accept only if all signers are allowed and at least one valid signature. + * Used when accepting a version of an object (sync); cardinality/deps require + * pair resolution and are not enforced here. + */ +export function validateSignaturesAgainstValidateurs( + message: MessageBase, + signatures: Signature[], + validateurs: Validateurs, +): ValidateSignaturesResult { + const allowed = buildAllowedPubkeysFromValidateurs(validateurs); + if (allowed.size === 0) { + return { + accept: false, + reason: 'validators_not_verifiable_no_cle_publique', + }; + } + const { valid, unauthorized } = verifyMessageSignaturesStrict( + message, + signatures, + allowed, + ); + if (unauthorized.length > 0) { + return { + accept: false, + reason: 'signature_cle_publique_not_authorized', + }; + } + if (valid.length === 0) { + return { + accept: false, + reason: 'no_validator_signature', + }; + } + return { accept: true }; +} + +/** + * Full check for sync: validators verifiable, sigs present, all allowed, ≥1 valid. + * Logs warnings on failure. Used by SyncService. + */ +export function validateMessageAValiderForSync( + msg: MessageBase, + sigList: Signature[], + validateurs: Validateurs, + logPrefix: string, +): boolean { + if (!canVerifyValidateurs(validateurs)) { + console.warn( + `${logPrefix} MessageAValider: no cle_publique in validateurs, skip`, + ); + return false; + } + if (sigList.length === 0) { + return false; + } + const result = validateSignaturesAgainstValidateurs( + msg, + sigList, + validateurs, + ); + if (!result.accept) { + if (result.reason !== undefined) { + console.warn(`${logPrefix} MessageAValider validation failed: ${result.reason}`); + } + return false; + } + return true; +}