Website-skeleton partie connectée, contrat en dur, navigate-login; UserWallet pairing-relay-status, redirect; website-data, proxy data, cryptographie, fixKnowledge

**Motivations:**
- Partie connectée du skeleton accessible seulement si pairing satisfait + relais OK, avec page type skeleton (avatar, notifications).
- Éviter « Aucun service disponible » : contrat présent en dur dans la page, transmis à l’iframe ; navigation évidente ou automatique vers login.
- Sécuriser postMessage (origine UserWallet uniquement) ; déployer data sur le proxy et certificat data.certificator.4nkweb.com.
- Vulgariser cryptographie (ECDH, AES-GCM, Schnorr, workflow, collecte signatures) ; documenter correctifs et architecture.

**Root causes:**
- Section connectée affichée sans vérifier pairing/relay ; possibilité de forger pairing-relay-status depuis la console.
- Iframe masquée ou /login chargé avant réception du contrat → graphe vide, redirection vers /services.
- Pas de contrôle d’origine sur les messages reçus ; pas de projet website-data ni config Nginx/certificat pour data.

**Correctifs:**
- Vérification msg.origin === USERWALLET_ORIGIN dans handleMessage (skeleton).
- Si session mais pas pairingRelayStatus : afficher iframe pour réception du statut, message « Vérification du statut… ».
- Contrat envoyé dès load iframe (init iframe.src = USERWALLET_ORIGIN) ; au clic « Se connecter », envoi contract + navigate-login (service, membre).
- UserWallet : écoute navigate-login → navigation /login?service=&membre= ; LoginScreen avec service+membre en URL ne redirige plus vers /services, dispatch E_SELECT_SERVICE / E_SELECT_MEMBER.

**Evolutions:**
- Message pairing-relay-status (iframe → parent) ; canShowConnectedSection exige login + pairing OK + relay OK ; page connectée avec header avatar + icône notifications.
- Skeleton : getLoginContext, sendNavigateLoginToIframe, onIframeLoad, loginRequested/iframeLoaded ; contrat envoyé avec serviceUuid, membreUuid.
- UserWallet : PairingRelayStatusMessage, envoi depuis HomeScreen/LoginScreen ; type navigate-login, handleNavigateLogin dans useChannel.
- Page cryptographie.html (workflow, algorithmes, collecte signatures) ; liens nav, build.
- website-data (Vite, channel, config), start/service/install ; configure-nginx-proxy + Certbot pour data.certificator.4nkweb.com.
- fixKnowledge (postmessage-origin, section-connectee-non-affichee) ; features (partie-connectee-pairing-relay, userwallet-iframe-key-isolation).

**Pages affectées:**
- website-skeleton (index, main, config, serviceContract, cryptographie, technique, membre, contrat, vite.config, README).
- userwallet (HomeScreen, LoginScreen, useChannel, iframeChannel, relay, crypto, iframe, Pairing*, RelaySettings, WordInputGrid, syncUpdateGraph, specs/synthese).
- website-data (nouveau), configure-nginx-proxy, docs DOMAINS_AND_PORTS README, features, fixKnowledge, userwallet features/docs.
This commit is contained in:
ncantu 2026-01-29 00:55:58 +01:00
parent 497bcf0819
commit f9fe0e3419
49 changed files with 3275 additions and 195 deletions

View File

@ -250,7 +250,34 @@ server {
}
EOF
# 7. Relay / api-relay (port 3019)
# 7. Website data (port 3025)
echo "📝 Configuration de data.certificator.4nkweb.com..."
${SUDO_CMD} tee "${NGINX_SITES_AVAILABLE}/data.certificator.4nkweb.com" > /dev/null << 'EOF'
# Website data (iframe data, non clés)
server {
listen 80;
server_name data.certificator.4nkweb.com;
access_log /var/log/nginx/data.certificator.4nkweb.com.access.log;
error_log /var/log/nginx/data.certificator.4nkweb.com.error.log;
location / {
proxy_pass http://192.168.1.105:3025;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
EOF
# 8. Relay / api-relay (port 3019)
echo "📝 Configuration de relay.certificator.4nkweb.com..."
${SUDO_CMD} tee "${NGINX_SITES_AVAILABLE}/relay.certificator.4nkweb.com" > /dev/null << 'EOF'
# API Relay (UserWallet)
@ -288,6 +315,7 @@ ${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/anchorage.certificator.4nkweb.com"
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/watermark.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/watermark.certificator.4nkweb.com"
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/userwallet.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/userwallet.certificator.4nkweb.com"
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/skeleton.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/skeleton.certificator.4nkweb.com"
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/data.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/data.certificator.4nkweb.com"
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/relay.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/relay.certificator.4nkweb.com"
# Tester la configuration Nginx
@ -319,6 +347,7 @@ DOMAINS=(
"watermark.certificator.4nkweb.com"
"userwallet.certificator.4nkweb.com"
"skeleton.certificator.4nkweb.com"
"data.certificator.4nkweb.com"
"relay.certificator.4nkweb.com"
)
@ -349,6 +378,7 @@ echo " - anchorage.certificator.4nkweb.com -> http://192.168.1.105:3010"
echo " - watermark.certificator.4nkweb.com -> http://192.168.1.105:3022"
echo " - userwallet.certificator.4nkweb.com -> http://192.168.1.105:3018"
echo " - skeleton.certificator.4nkweb.com -> http://192.168.1.105:3024"
echo " - data.certificator.4nkweb.com -> http://192.168.1.105:3025"
echo " - relay.certificator.4nkweb.com -> http://192.168.1.105:3019"
echo ""
echo "⚠️ Note: Si les services tournent sur une autre machine,"

View File

@ -1,100 +1,100 @@
⏳ Traitement: 210000/215703 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 5146
- UTXOs toujours disponibles: 5146
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 63307
- Non dépensés: 5091
- Total UTXOs: 283165
- Dépensés: 124307
- Non dépensés: 158858
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 213647
📊 UTXOs à vérifier: 141938
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 223744
📊 UTXOs disponibles dans Bitcoin: 272415
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/223744 UTXOs insérés...
⏳ Traitement: 20000/223744 UTXOs insérés...
⏳ Traitement: 30000/223744 UTXOs insérés...
⏳ Traitement: 40000/223744 UTXOs insérés...
⏳ Traitement: 50000/223744 UTXOs insérés...
⏳ Traitement: 60000/223744 UTXOs insérés...
⏳ Traitement: 70000/223744 UTXOs insérés...
⏳ Traitement: 80000/223744 UTXOs insérés...
⏳ Traitement: 90000/223744 UTXOs insérés...
⏳ Traitement: 100000/223744 UTXOs insérés...
⏳ Traitement: 110000/223744 UTXOs insérés...
⏳ Traitement: 120000/223744 UTXOs insérés...
⏳ Traitement: 130000/223744 UTXOs insérés...
⏳ Traitement: 140000/223744 UTXOs insérés...
⏳ Traitement: 150000/223744 UTXOs insérés...
⏳ Traitement: 160000/223744 UTXOs insérés...
⏳ Traitement: 170000/223744 UTXOs insérés...
⏳ Traitement: 180000/223744 UTXOs insérés...
⏳ Traitement: 190000/223744 UTXOs insérés...
⏳ Traitement: 200000/223744 UTXOs insérés...
⏳ Traitement: 210000/223744 UTXOs insérés...
⏳ Traitement: 220000/223744 UTXOs insérés...
⏳ Traitement: 10000/272415 UTXOs insérés...
⏳ Traitement: 20000/272415 UTXOs insérés...
⏳ Traitement: 30000/272415 UTXOs insérés...
⏳ Traitement: 40000/272415 UTXOs insérés...
⏳ Traitement: 50000/272415 UTXOs insérés...
⏳ Traitement: 60000/272415 UTXOs insérés...
⏳ Traitement: 70000/272415 UTXOs insérés...
⏳ Traitement: 80000/272415 UTXOs insérés...
⏳ Traitement: 90000/272415 UTXOs insérés...
⏳ Traitement: 100000/272415 UTXOs insérés...
⏳ Traitement: 110000/272415 UTXOs insérés...
⏳ Traitement: 120000/272415 UTXOs insérés...
⏳ Traitement: 130000/272415 UTXOs insérés...
⏳ Traitement: 140000/272415 UTXOs insérés...
⏳ Traitement: 150000/272415 UTXOs insérés...
⏳ Traitement: 160000/272415 UTXOs insérés...
⏳ Traitement: 170000/272415 UTXOs insérés...
⏳ Traitement: 180000/272415 UTXOs insérés...
⏳ Traitement: 190000/272415 UTXOs insérés...
⏳ Traitement: 200000/272415 UTXOs insérés...
⏳ Traitement: 210000/272415 UTXOs insérés...
⏳ Traitement: 220000/272415 UTXOs insérés...
⏳ Traitement: 230000/272415 UTXOs insérés...
⏳ Traitement: 240000/272415 UTXOs insérés...
⏳ Traitement: 250000/272415 UTXOs insérés...
⏳ Traitement: 260000/272415 UTXOs insérés...
⏳ Traitement: 270000/272415 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 213647
- UTXOs toujours disponibles: 213647
- UTXOs vérifiés: 141938
- UTXOs toujours disponibles: 141938
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 283165
- Dépensés: 69526
- Non dépensés: 213639
- Dépensés: 141287
- Non dépensés: 141878
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 211454
📊 UTXOs à vérifier: 124298
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 241303
📊 UTXOs disponibles dans Bitcoin: 270669
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/241303 UTXOs insérés...
⏳ Traitement: 20000/241303 UTXOs insérés...
⏳ Traitement: 30000/241303 UTXOs insérés...
⏳ Traitement: 40000/241303 UTXOs insérés...
⏳ Traitement: 50000/241303 UTXOs insérés...
⏳ Traitement: 60000/241303 UTXOs insérés...
⏳ Traitement: 70000/241303 UTXOs insérés...
⏳ Traitement: 80000/241303 UTXOs insérés...
⏳ Traitement: 90000/241303 UTXOs insérés...
⏳ Traitement: 100000/241303 UTXOs insérés...
⏳ Traitement: 110000/241303 UTXOs insérés...
⏳ Traitement: 120000/241303 UTXOs insérés...
⏳ Traitement: 130000/241303 UTXOs insérés...
⏳ Traitement: 140000/241303 UTXOs insérés...
⏳ Traitement: 150000/241303 UTXOs insérés...
⏳ Traitement: 160000/241303 UTXOs insérés...
⏳ Traitement: 170000/241303 UTXOs insérés...
⏳ Traitement: 180000/241303 UTXOs insérés...
⏳ Traitement: 190000/241303 UTXOs insérés...
⏳ Traitement: 200000/241303 UTXOs insérés...
⏳ Traitement: 210000/241303 UTXOs insérés...
⏳ Traitement: 220000/241303 UTXOs insérés...
⏳ Traitement: 230000/241303 UTXOs insérés...
⏳ Traitement: 240000/241303 UTXOs insérés...
⏳ Traitement: 10000/270669 UTXOs insérés...
⏳ Traitement: 20000/270669 UTXOs insérés...
⏳ Traitement: 30000/270669 UTXOs insérés...
⏳ Traitement: 40000/270669 UTXOs insérés...
⏳ Traitement: 50000/270669 UTXOs insérés...
⏳ Traitement: 60000/270669 UTXOs insérés...
⏳ Traitement: 70000/270669 UTXOs insérés...
⏳ Traitement: 80000/270669 UTXOs insérés...
⏳ Traitement: 90000/270669 UTXOs insérés...
⏳ Traitement: 100000/270669 UTXOs insérés...
⏳ Traitement: 110000/270669 UTXOs insérés...
⏳ Traitement: 120000/270669 UTXOs insérés...
⏳ Traitement: 130000/270669 UTXOs insérés...
⏳ Traitement: 140000/270669 UTXOs insérés...
⏳ Traitement: 150000/270669 UTXOs insérés...
⏳ Traitement: 160000/270669 UTXOs insérés...
⏳ Traitement: 170000/270669 UTXOs insérés...
⏳ Traitement: 180000/270669 UTXOs insérés...
⏳ Traitement: 190000/270669 UTXOs insérés...
⏳ Traitement: 200000/270669 UTXOs insérés...
⏳ Traitement: 210000/270669 UTXOs insérés...
⏳ Traitement: 220000/270669 UTXOs insérés...
⏳ Traitement: 230000/270669 UTXOs insérés...
⏳ Traitement: 240000/270669 UTXOs insérés...
⏳ Traitement: 250000/270669 UTXOs insérés...
⏳ Traitement: 260000/270669 UTXOs insérés...
⏳ Traitement: 270000/270669 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 211454
- UTXOs toujours disponibles: 211454
- UTXOs vérifiés: 124298
- UTXOs toujours disponibles: 124298
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 283165
- Dépensés: 71719
- Non dépensés: 211446
- Dépensés: 158927
- Non dépensés: 124238
✅ Synchronisation terminée

View File

@ -22,6 +22,7 @@ Ce document liste tous les domaines, ports et services de l'infrastructure Certi
| `faucet.certificator.4nkweb.com` | API Faucet | 3021 | API REST pour distribuer des sats |
| `mempool.4nkweb.com` | Mempool | 3015 | Explorateur de blockchain Bitcoin |
| `skeleton.certificator.4nkweb.com` | Website skeleton | 3024 | Site squelette iframe UserWallet |
| `data.certificator.4nkweb.com` | Website data | 3025 | Iframe data (non clés), site ↔ data ↔ userwallet |
### Configuration Nginx
@ -44,6 +45,7 @@ Tous les domaines sont routés via le proxy Nginx sur le serveur `192.168.1.100`
| 3022 | API Filigrane | `api-filigrane` | `api-filigrane/filigrane-api.service` |
| 3023 | API ClamAV | `api-clamav` | `api-clamav/clamav-api.service` |
| 3024 | Website skeleton | `website-skeleton` | `website-skeleton/website-skeleton.service` |
| 3025 | Website data | `website-data` | (à définir, ex. `website-data/website-data.service`) |
**Important :** Les ports 3010, 3020, 3021, 3022, 3023, 3024 sont fixes et définis dans les fichiers de service systemd. Ils ne peuvent pas être modifiés sans modifier les services.

View File

@ -65,6 +65,8 @@ Ce dossier contient toute la documentation nécessaire pour la maintenance et l'
- Configuration (origine UserWallet, validateurs)
- Utilisation, messages postMessage, références
- **website-data** (`website-data/`, `features/website-data-architecture.md`) : Iframe data (data.certificator.4nkweb.com), flux site ↔ data (+notifications) ↔ userwallet (relais).
- **[PAIRING_PUBLIC_KEY_ENCODING.md](./PAIRING_PUBLIC_KEY_ENCODING.md)** : Encodage de la clé publique dans les mots BIP32
- Problème identifié (récupération d'un pair par UUID sur un relais)
- Solution implémentée (encodage direct de la clé publique)

View File

@ -1,6 +1,6 @@
# UserWallet Champs obligatoires et attributs CNIL (complément specs)
**Author:** Équipe 4NK
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
@ -13,7 +13,7 @@ Documenter le complément de spécifications : champs (Champ) obligatoires des c
## 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`.
- **Champs obligatoires (rôles)** : tous les contrats ont certains des 11 types : partage institutions, RSSI, Correspondant CNIL, Responsable cybersécurité, support infogérant / admin / N1 / N2 / N3, **validation (contrat)**, **validation du login**. 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

View File

@ -0,0 +1,39 @@
# website-data — architecture site <-> data <-> userwallet
**Objectif**
Projet iframe **data** (data.certificator.4nkweb.com) dédié aux **données non clés**, qui communique avec le **site** et avec **UserWallet** (clés, relais). Flux:
```
site <-> data (+notifications) <-> userwallet (sur les relais)
```
**Impacts**
- Nouveau projet `website-data/` (Vite, TypeScript), servi sur data.certificator.4nkweb.com (port 3025).
- Le site parent intègre une seule iframe **Data**. Data embarque **UserWallet** en iframe enfant.
- Contrat, auth, login-proof, etc. transitent par Data entre site et UserWallet.
- Les clés et la crypto restent dans UserWallet ; Data ne fait aucune opération de signature/chiffrement.
**Modifications**
- **website-data** : `package.json`, `vite.config.ts`, `index.html`, `src/config.ts`, `src/channel.ts`, `src/main.ts`.
- Messages postMessage : Site ↔ Data (contract, auth-request, data-request, data-response, notification, pong, error, login-proof, auth-response, service-status). Data ↔ UserWallet : mêmes types applicables, relayés.
- Notifications : Data → Site (`notification` avec `kind` / `detail`). Actuellement pour userwallet:login-proof, userwallet:auth-response, userwallet:error. Placeholder pour notifications relay (data non clés).
**Modalités de déploiement**
- Build `website-data` (`npm run build`), servir `dist/` sur le port 3025.
- Configurer Nginx (ou équivalent) pour data.certificator.4nkweb.com → backend 3025, certificat Lets Encrypt.
- Saligner sur la procédure utilisée pour website-skeleton / userwallet (cf. docs infrastructure 4NK).
**Modalités danalyse**
- Ouvrir le site parent avec iframe Data (src = data.certificator.4nkweb.com ou localhost:3025 en dev).
- Envoyer `contract`, `auth-request` au Data iframe ; vérifier relay vers UserWallet et réponses (login-proof, etc.) remontées au site.
- Vérifier les `notification` reçues par le site (kind `userwallet:*`).
**Pages affectées**
- `website-data/` (nouveau)
- `features/website-data-architecture.md` (ce document)

View File

@ -0,0 +1,21 @@
# Website-skeleton: partie connectée conditionnée au pairing et au relais
**Objectif:** La partie connectée du website-skeleton nest accessible que si le statut pairing est satisfait (Requis: Oui, Satisfait: Oui) et le statut réseau relais est OK. La page connectée adopte un style skeleton avec avatar et icône notifications.
**Impacts:**
- UserWallet envoie au parent (postMessage) un message `pairing-relay-status` avec `pairingSatisfied` et `relayOk`.
- Website-skeleton affiche la section connectée uniquement lorsque lutilisateur est connecté (login-proof reçu) **et** que le dernier `pairing-relay-status` reçu indique les deux conditions remplies.
- La page connectée affiche un en-tête type skeleton avec photo davatar (placeholder) et bouton icône notifications.
**Modifications:**
- **userwallet**
- `src/utils/iframeChannel.ts`: nouveau type de message `pairing-relay-status` et interface `PairingRelayStatusMessage`.
- `src/components/HomeScreen.tsx`: en iframe, envoi de `pairing-relay-status` lorsque pairing et relay changent.
- `src/components/LoginScreen.tsx`: en iframe, envoi de `pairing-relay-status` (pairing satisfait, relay OK) pour que le parent reçoive létat avant ou avec le login-proof.
- **website-skeleton**
- `src/main.ts`: réception de `pairing-relay-status`, stockage du dernier état; `canShowConnectedSection()` exige login + pairing satisfait + relay OK; `updateUI()` utilise cette condition pour afficher la section connectée. Vérification de `msg.origin === USERWALLET_ORIGIN` dans `handleMessage` pour naccepter que les messages provenant de liframe UserWallet (évite quun script ou la console forge un message et force laccès à la partie connectée).
- `index.html`: section connectée refaite avec en-tête (avatar placeholder, icône notifications), liens et bouton déconnexion inchangés.
**Modalités de déploiement:** Déploiement classique du website-skeleton et du userwallet (build puis déploiement des artefacts).
**Modalités danalyse:** Vérifier en conditions réelles : 1) sans pairing/relay OK, après login-proof la section connectée ne saffiche pas ; 2) avec pairing satisfait et relay OK, après login-proof la section connectée saffiche avec avatar et icône notifications ; 3) si le statut pairing ou relay repasse à non satisfait après connexion, la section connectée disparaît.

View File

@ -0,0 +1,50 @@
# UserWallet iframe: Invalid contract structure / types_names_chiffres undefined
**Problème**
En iframe (skeleton → UserWallet), console :
- `Invalid contract structure received via channel: missing required fields`
- `Error updating graph: TypeError: Cannot read properties of undefined (reading 'types_names_chiffres')` dans `syncUpdateGraph.ts`
**Impacts**
- Le contrat skeleton nest pas accepté par UserWallet.
- Le graphe nest pas mis à jour ; le login skeleton échoue côté graphe.
**Root causes**
1. **useChannel** exige sur le contrat principal `uuid`, `version`, `validateurs`. Le skeleton nenvoyait pas `version` → validation « missing required fields ».
2. **syncUpdateGraph** lit `msg.types.types_names_chiffres`. Le skeleton mettait `types_names_chiffres` dans `datajson` uniquement, pas de `types` à la racine → `msg.types` undefined → crash.
**Correctifs**
1. **userwallet `syncUpdateGraph.ts`**
- Vérifier `msg?.types?.types_names_chiffres` avant utilisation.
- Si absent : `console.warn` avec `uuid` et `return` (ne pas ajouter au graphe).
- Évite le crash et signale les messages mal formés.
2. **website-skeleton `serviceContract.ts`**
- Contrat : ajouter `version: '1.0'` et
`types: { types_uuid: [SKELETON_CONTRACT_TYPE_UUID], types_names_chiffres: 'contrat' }`.
- Action login : idem avec `version: '1.0'` et
`types: { types_uuid: [SKELETON_ACTION_TYPE_UUID], types_names_chiffres: 'action,login' }`.
- Alignement avec `MessageBase` / `MessageAValider` attendus par UserWallet.
- `datajson` inchangé (types_uuid, types_names_chiffres, etc.).
**Fichiers modifiés**
- `userwallet/src/services/syncUpdateGraph.ts`
- `website-skeleton/src/serviceContract.ts`
- `website-skeleton/src/config.ts` (Contrat / Action : `version?`, `types?` documentés)
**Modalités de déploiement**
- Rebuild UserWallet et website-skeleton.
- Redéployer les deux selon linfra (skeleton, UserWallet iframe).
**Modalités danalyse**
- Reproduire : ouvrir skeleton en iframe UserWallet, observer la console.
- Vérifier labsence de « Invalid contract structure » et de « types_names_chiffres » undefined.
- Vérifier que le graphe reçoit bien contrat + action login (login possible).

View File

@ -0,0 +1,23 @@
# Website-skeleton: vérification de lorigine des messages postMessage
**Problème:** Un utilisateur pouvait forcer laccès à la partie connectée en exécutant dans la console du navigateur un `window.postMessage({ type: 'pairing-relay-status', payload: { pairingSatisfied: true, relayOk: true } }, '*')`. Si une session (login-proof) existait déjà, la section connectée saffichait sans que pairing/relais soient réellement OK.
**Impacts:** Risque de contournement des conditions daccès (pairing satisfait, relais OK) et affichage de la partie connectée dans un état non conforme.
**Cause:** Le handler `handleMessage` du website-skeleton ne vérifiait pas lorigine du message ; tout script sexécutant dans la même page (ou la console) pouvait envoyer un faux `pairing-relay-status` ou `login-proof` (pour login-proof la vérification côté preuve reste, mais lorigine nétait pas contrôlée).
**Root cause:** Absence de contrôle de `event.origin` sur les messages reçus via `window.addEventListener('message', handleMessage)`.
**Correctifs:**
- Au début de `handleMessage`, ignorer tout message dont `msg.origin !== USERWALLET_ORIGIN`. Seuls les messages émis par liframe UserWallet (même origine que `USERWALLET_ORIGIN`) sont traités. Un `postMessage` depuis la console a pour origine lURL du site skeleton, pas celle du UserWallet, donc il est rejeté.
**Evolutions:** Aucune.
**Pages affectées:**
- `website-skeleton/src/main.ts` (vérification `msg.origin !== USERWALLET_ORIGIN` en tête de `handleMessage`)
- `features/website-skeleton-partie-connectee-pairing-relay.md` (mention de la vérification dorigine)
- `fixKnowledge/website-skeleton-postmessage-origin-check.md` (ce document)
**Modalités de déploiement:** Déploiement classique du website-skeleton.
**Modalités danalyse:** En console sur la page skeleton, exécuter `window.postMessage({ type: 'pairing-relay-status', payload: { pairingSatisfied: true, relayOk: true } }, '*')` alors quune session existe : la section connectée ne doit pas safficher si pairing/relay ne sont pas OK (message rejeté car origine ≠ UserWallet).

View File

@ -0,0 +1,26 @@
# Website-skeleton: section connectée non affichée malgré pairing/relay OK
**Problème:** L'utilisateur voit dans UserWallet (HomeScreen) le statut « Pairing Satisfait: Oui » et « Statut réseau relais: OK », mais la page skeleton ne redirige pas vers la section connectée.
**Impacts:** L'utilisateur connecté (session existante) qui rafraîchit la page skeleton reste sur la section « Se connecter » au lieu de voir la section connectée.
**Cause:** La section connectée nest affichée que si `canShowConnectedSection()` est vrai : session (login-proof) **et** dernier `pairing-relay-status` reçu avec `pairingSatisfied` et `relayOk`. Après un rafraîchissement, la session est en sessionStorage mais `pairingRelayStatus` nest pas persisté. Liframe UserWallet est dans un conteneur en `display: none` tant quon affiche la section login ; dans ce cas liframe peut ne pas charger (ou charger avec retard), donc le parent ne reçoit jamais `pairing-relay-status` et `pairingRelayStatus` reste `null`.
**Root cause:** Quand lutilisateur a une session mais pas encore de `pairing-relay-status`, liframe est cachée donc elle ne charge pas (ou pas à temps) et nenvoie pas le message au parent.
**Correctifs:**
- Quand `isLoggedIn()` est vrai et `pairingRelayStatus === null`, afficher liframe (conteneur en `display: block`) pour quelle charge et envoie `pairing-relay-status`.
- Nouvelle branche dans `updateUI()` : `else if (isLoggedIn() && pairingRelayStatus === null)``showLoginInterfaceWithIframe(true)`.
- `showLoginInterfaceWithIframe(waitingForStatus)` : si `waitingForStatus` est vrai, afficher liframe et le message « Vérification du statut pairing et relais… » ; sinon comportement identique à `showLoginInterface()` (iframe cachée).
- Élément `#waiting-status` dans la section login, affiché uniquement en attente du statut.
**Evolutions:** Aucune.
**Pages affectées:**
- `website-skeleton/src/main.ts` (updateUI, showLoginInterfaceWithIframe, ref waitingStatusEl)
- `website-skeleton/index.html` (élément #waiting-status)
- `fixKnowledge/website-skeleton-section-connectee-non-affichee.md` (ce document)
**Modalités de déploiement:** Build et déploiement classique du website-skeleton.
**Modalités danalyse:** 1) Se connecter sur le skeleton (login-proof reçu, section connectée affichée). 2) Rafraîchir la page : la section « Vérification du statut pairing et relais… » et liframe saffichent brièvement, puis la section connectée saffiche dès réception de `pairing-relay-status`. 3) Sans session : pas de changement (clic « Se connecter » puis login dans liframe pour obtenir la section connectée).

View File

@ -1,30 +1,32 @@
# Complément de specs Champs obligatoires et attributs CNIL
**Author:** Équipe 4NK
**Date:** 2026-01-26
**Author:** Équipe 4NK
**Date:** 2026-01-26
Référence : `specs.md` (Contrat, Champ, MessageBase, DataJson).
---
## 1. Champs obligatoires des contrats
## 1. Champs obligatoires des contrats (rôles)
Tous les contrats ont **certains** des champs (objets `Champ`) suivants. Chaque type correspond à un usage métier précis ; un contrat donné en possède un sous-ensemble selon le contexte.
Tous les contrats ont **certains** des champs (objets `Champ`) suivants. Chaque type correspond à un **rôle** / usage métier précis ; un contrat donné en possède un sous-ensemble selon le contexte.
| Type de Champ | Description |
|---------------|-------------|
| Partage avec les institutions | Champ dédié au partage de données avec des institutions |
| Type de Champ (rôle) | Description |
|----------------------|-------------|
| Partage avec les institutions | Partage de données avec des institutions |
| Messages au RSSI | Messages au RSSI de la société responsable du service |
| Messages au Correspondant CNIL | Messages au Correspondant CNIL de la société responsable du service |
| Messages au Responsable cybersécurité | Messages au Responsable cybersécurité au bord de la société responsable du service |
| Messages de support infogérant | Messages de support de l'infogérant du service (peut inclure un membre du miner pour la gestion des clés API) |
| Messages de support administrateur système | Messages de support de ladministrateur système du service |
| Messages de support niveau 1 | Messages de support de niveau 1 du service |
| Messages de support niveau 2 | Messages de support de niveau 2 du service |
| Messages de support niveau 3 | Messages de support de niveau 3 du service |
| Messages au Responsable cybersécurité | Messages au Responsable cybersécurité de la société responsable du service |
| Messages de support infogérant | Support infogérant (peut inclure membre du miner pour clés API) |
| Messages de support administrateur système | Support admin système du service |
| Messages de support niveau 1 | Support N1 du service |
| Messages de support niveau 2 | Support N2 du service |
| Messages de support niveau 3 | Support N3 du service |
| **Validation (contrat)** | Validation du contrat ; validateurs du contrat (`contrat.validateurs.membres_du_role`) |
| **Validation du login** | Validation du login ; validateurs de l'action login (`action.validateurs_action.membres_du_role`) |
- Les `Champ` sont des `MessageAValider` avec `contrats_parents_uuid` (au moins 1).
- Lidentification du type (partage, RSSI, CNIL, cybersécurité, support N1/N2/N3, etc.) se fait via `types.types_names_chiffres` / `types_uuid` ou via des métadonnées dans `datajson`, selon les conventions du catalogue.
- Lidentification du type / rôle (partage, RSSI, CNIL, cybersécurité, support N1/N2/N3, validation, validation login, etc.) se fait via `types.types_names_chiffres` / `types_uuid` ou via des métadonnées dans `datajson`, selon les conventions du catalogue.
---
@ -34,9 +36,9 @@ Pour les objets concernés (ex. contrats, champs), la partie **`datajson`** peut
### 2.1 Usage et partage avec les tiers
- **Raisons de lusage avec les tiers et description du tiers**
- **Raisons de lusage avec les tiers et description du tiers**
Tableau de couples `[raisons, tiers]` : pour chaque usage avec un tiers, les raisons et la description du tiers.
- **Raisons du partage avec les tiers et description du tiers**
- **Raisons du partage avec les tiers et description du tiers**
Tableau de couples `[raisons, tiers]` : pour chaque partage avec un tiers, les raisons et la description du tiers.
Structure suggérée (à préciser dans le catalogue) :
@ -54,7 +56,7 @@ Structure suggérée (à préciser dans le catalogue) :
### 2.2 Conditions de conservation
- **Conditions de conservation**
- **Conditions de conservation**
Objet JSON contenant **au moins** le **délai dexpiration** (durée de conservation des données).
Exemple :
@ -76,7 +78,7 @@ Exemple :
| Domaine | Contenu |
|--------|---------|
| Champs obligatoires | Sous-ensemble des 9 types (partage, RSSI, CNIL, cybersécurité, support infogérant / admin / N1 / N2 / N3) selon le contrat |
| Champs obligatoires (rôles) | Sous-ensemble des 11 types : partage, RSSI, CNIL, cybersécurité, support infogérant / admin / N1 / N2 / N3, **validation (contrat)**, **validation du login** |
| `datajson` CNIL | `raisons_usage_tiers`, `raisons_partage_tiers` (tableaux [raisons, tiers]) ; `conditions_conservation` (JSON avec au moins `delai_expiration`) |
---

View File

@ -1,6 +1,6 @@
# Synthèse structurée UserWallet & API Relay
**Author:** Équipe 4NK
**Author:** Équipe 4NK
**Date:** 2026-01-26
## 1. userwallet/docs
@ -15,7 +15,7 @@
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`, `membre_miner_uuid` pour les champs infogérant). Voir `features/userwallet-membre-miner-infogerant.md`.
**Complément** : `specs-champs-obligatoires-cnil.md` — champs obligatoires / rôles (partage, RSSI, CNIL, cybersécurité, support N1/N2/N3, validation contrat, validation login, etc.) et attributs CNIL dans `datajson` (`raisons_usage_tiers`, `raisons_partage_tiers`, `conditions_conservation`, `membre_miner_uuid` pour les champs infogérant). Voir `features/userwallet-membre-miner-infogerant.md`.
- **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.

View File

@ -0,0 +1,34 @@
# UserWallet iframe — cloisonnement des clés et du chiffrement
**Objectif**
Garantir quaucune clé secrète ne quitte liframe UserWallet et quaucune opération de signature ou de chiffrement nest effectuée en dehors du domaine de liframe.
**Règles**
1. **Clés**
- Seules les **clés publiques** peuvent sortir de liframe (via postMessage vers le parent).
- Les clés privées (`privateKey`, `cle_privee`) ne doivent jamais être envoyées au parent ni à un autre domaine.
2. **Crypto**
- Toutes les opérations de **signature**, **chiffrement** et **déchiffrement** sont réalisées **uniquement dans liframe** (UserWallet).
- Le parent (site intégrateur, ex. skeleton) ne doit **jamais** signer, chiffrer ni déchiffrer. Il peut uniquement **vérifier** des signatures à laide des clés publiques (ex. `verifyLoginProof`).
**Implémentation**
- **iframeChannel** : `sendToChannel` appelle `assertNoSecretsInChannelPayload` avant `postMessage`. Toute présence de `privateKey` ou `cle_privee` dans le payload (récursif) lève une erreur ; le message nest pas envoyé.
- **iframe.ts** : `sendMessageToParent` appelle aussi `assertNoSecretsInChannelPayload` sur le payload avant `postMessage`. Tous les envois vers le parent passent par cette vérification.
- **Messages envoyés au parent** : `auth-response` (signature, publicKey, message), `login-proof` (challenge, signatures avec `cle_publique`), `error`, `service-status`, `pairing-relay-status` (pairingSatisfied, relayOk). Aucun de ces messages ne contient de clé secrète.
- **Messages reçus du parent** : `auth-request`, `contract`. Le contrat peut contenir des `cle_publique` de service (validateurs) ; ce sont des clés publiques. Le parent nenvoie jamais de clé privée.
- **Parent (intégrateur)** : Ne doit jamais signer, chiffrer ni déchiffrer. Il peut uniquement **vérifier** des signatures (ex. `verifyLoginProof` avec clés publiques). Ex. skeleton : vérification uniquement, pas de crypto secrète.
**Contrôles**
- Avant tout envoi vers le parent : vérification systématique des payloads pour `privateKey` et `cle_privee`.
- Aucun usage de `privateKey` / `cle_privee` dans les types de messages définis pour le canal iframe (`AuthResponseMessage`, `LoginProofMessage`, etc.).
**Pages affectées**
- `userwallet/src/utils/iframeChannel.ts` (assertion, `sendToChannel`)
- `userwallet/src/utils/iframe.ts` (assertion, `sendMessageToParent`)
- `userwallet/features/userwallet-iframe-key-isolation.md` (ce document)

View File

@ -4,6 +4,7 @@ import { useIdentity } from '../hooks/useIdentity';
import { isPairingSatisfied, hasRemotePair } from '../utils/pairing';
import { usePairingConnected } from '../hooks/usePairingConnected';
import { getStoredRelays } from '../utils/relay';
import { isInIframe, sendToChannel } from '../utils/iframeChannel';
import type { LocalIdentity } from '../types/identity';
import { PairingSetupBlock } from './PairingSetupBlock';
@ -54,6 +55,19 @@ export function HomeScreen(): JSX.Element {
logHomeStatus(identity, pairingSatisfied, relayStatus, relays.length);
}, [identity, pairingSatisfied, relayStatus, relays.length]);
useEffect(() => {
if (!isInIframe()) {
return;
}
sendToChannel({
type: 'pairing-relay-status',
payload: {
pairingSatisfied,
relayOk: relayStatus === 'OK',
},
});
}, [pairingSatisfied, relayStatus]);
useEffect(() => {
if (!showSetupBlock || identity === null || hasScrolledAndLoggedRef.current) {
if (!showSetupBlock) {

View File

@ -9,6 +9,7 @@ import { getStoredRelays } from '../utils/relay';
import { GraphResolver } from '../services/graphResolver';
import { LoginBuilder } from '../services/loginBuilder';
import { useChannel } from '../hooks/useChannel';
import { isInIframe, sendToChannel } from '../utils/iframeChannel';
import { isPairingSatisfied, getPairsForMember } from '../utils/pairing';
import {
buildAllowedPubkeys,
@ -65,10 +66,31 @@ export function LoginScreen(): JSX.Element {
} | null>(null);
const graphResolver = useState(() => new GraphResolver())[0];
const pairingSatisfied = isPairingSatisfied();
const relayOk = getStoredRelays().length > 0;
// Rediriger selon l'état de la machine à états
useEffect(() => {
if (!isInIframe()) {
return;
}
sendToChannel({
type: 'pairing-relay-status',
payload: {
pairingSatisfied,
relayOk,
},
});
}, [pairingSatisfied, relayOk]);
// Rediriger selon l'état de la machine à états. Si service+membre en URL (parent iframe),
// ne pas rediriger vers /services; dispatcher E_SELECT_* pour aller en S_LOGIN_BUILD_PATH.
useEffect(() => {
if (loginState === 'S_LOGIN_SELECT_SERVICE') {
if (serviceUuid !== '' && membreUuid !== '') {
dispatch({ type: 'E_SELECT_SERVICE', serviceUuid });
dispatch({ type: 'E_SELECT_MEMBER', membreUuid });
return;
}
navigate('/services');
return;
}
@ -80,7 +102,7 @@ export function LoginScreen(): JSX.Element {
navigate('/services');
return;
}
}, [loginState, serviceUuid, navigate]);
}, [loginState, serviceUuid, membreUuid, navigate, dispatch]);
const handleBuildPath = useCallback(async (): Promise<void> => {
if (serviceUuid === '' || membreUuid === '') {

View File

@ -139,18 +139,63 @@ export function PairManagementScreen(): JSX.Element {
}}
>
<p>
<strong>Mots (8):</strong>
<strong>Mots (17):</strong>
</p>
<p
<div
style={{
fontFamily: 'monospace',
fontSize: '1rem',
wordBreak: 'break-word',
margin: 0,
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.5rem',
}}
>
{words.join(' ')}
</p>
{words.map((word, wordIndex) => {
const visible = visibleWordIndices.get(pair.uuid)?.has(wordIndex) ?? false;
return (
<div
key={wordIndex}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
border: '1px solid #ddd',
}}
>
<span style={{ fontSize: '0.75rem', color: '#666', fontWeight: 'bold' }}>
{wordIndex + 1}:
</span>
<span
style={{
fontFamily: 'monospace',
fontSize: '0.9rem',
minWidth: '80px',
}}
>
{visible ? `${wordIndex + 1}. ${word}` : '•••••'}
</span>
<button
type="button"
onClick={() => handleToggleWordVisibility(pair.uuid, wordIndex)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
fontSize: '0.875rem',
color: '#666',
}}
aria-label={`${visible ? 'Masquer' : 'Afficher'} le mot ${wordIndex + 1}`}
title={`${visible ? 'Masquer' : 'Afficher'} le mot ${wordIndex + 1}`}
>
{visible ? '👁' : '👁‍🗨'}
</button>
</div>
);
})}
</div>
</div>
)}
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem' }}>

View File

@ -23,6 +23,19 @@ export function PairingDisplayScreen(): JSX.Element {
const [success, setSuccess] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [justConnected, setJustConnected] = useState(false);
const [visibleWordIndices, setVisibleWordIndices] = useState<Set<number>>(new Set());
const toggleWordVisibility = (index: number): void => {
setVisibleWordIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
useEffect(() => {
if (identity !== null && identity.publicKey !== undefined) {
@ -106,7 +119,6 @@ export function PairingDisplayScreen(): JSX.Element {
if (success) {
const showConnected = pairingConnected || justConnected;
const words2ndText = words2nd.length > 0 ? words2nd.join(' ') : '—';
return (
<main>
{showConnected && (
@ -134,44 +146,63 @@ export function PairingDisplayScreen(): JSX.Element {
<h2 id="words-2nd-heading" style={{ marginTop: 0, marginBottom: '0.5rem' }}>
Mots du 2 appareil à copier sur le 1ʳ
</h2>
<p
aria-label="Mots 2e appareil"
style={{
fontFamily: 'monospace',
fontSize: '1.1rem',
wordBreak: 'break-word',
margin: 0,
padding: '0.75rem',
backgroundColor: 'var(--color-background)',
color: 'var(--color-text)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
{words2ndText}
</p>
{identity !== null && (
<>
<h3 id="pubkey-2nd-heading" style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>
Clé publique de ce dispositif à copier sur le 1ʳ (hex)
</h3>
<p
aria-label="Clé publique 2e appareil"
style={{
fontFamily: 'monospace',
fontSize: '0.9rem',
wordBreak: 'break-all',
margin: 0,
padding: '0.5rem',
backgroundColor: 'var(--color-background)',
color: 'var(--color-text)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
{identity.publicKey}
</p>
</>
{words2nd.length > 0 ? (
<div
aria-label="Mots 2e appareil"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
fontFamily: 'monospace',
fontSize: '1rem',
marginTop: '0.5rem',
padding: '0.75rem',
backgroundColor: 'var(--color-background)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
{words2nd.map((word, index) => {
const visible = visibleWordIndices.has(index);
return (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: 'var(--color-info-bg, #dbeafe)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
<span style={{ fontWeight: 'bold' }}>{index + 1}.</span>
<span style={{ minWidth: '80px' }}>
{visible ? word : '•••••'}
</span>
<button
type="button"
onClick={() => toggleWordVisibility(index)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary, #666)',
}}
aria-label={`${visible ? 'Masquer' : 'Afficher'} le mot ${index + 1}`}
title={`${visible ? 'Masquer' : 'Afficher'} le mot ${index + 1}`}
>
{visible ? '👁' : '👁‍🗨'}
</button>
</div>
);
})}
</div>
) : (
<p style={{ margin: 0, padding: '0.75rem' }}></p>
)}
</div>
<p>

View File

@ -28,6 +28,19 @@ export function PairingSetupBlock(): JSX.Element {
const [remoteError, setRemoteError] = useState<string | null>(null);
const [hasCopiedToSecondDevice, setHasCopiedToSecondDevice] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [visibleWordIndices, setVisibleWordIndices] = useState<Set<number>>(new Set());
const toggleWordVisibility = (index: number): void => {
setVisibleWordIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
useEffect(() => {
if (identity !== null && identity.publicKey !== undefined) {
@ -115,12 +128,56 @@ export function PairingSetupBlock(): JSX.Element {
<p>
<strong>Mots du 1ʳ appareil</strong> à saisir sur le 2 (QR) :
</p>
<p
<div
aria-label="Mots 1er appareil"
style={{ fontFamily: 'monospace', fontSize: '1rem', wordBreak: 'break-word' }}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
fontFamily: 'monospace',
fontSize: '1rem',
marginTop: '0.5rem',
}}
>
{words.join(' ')}
</p>
{words.map((word, index) => {
const visible = visibleWordIndices.has(index);
return (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: 'var(--color-info-bg, #e8f4fd)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
<span style={{ fontWeight: 'bold' }}>{index + 1}.</span>
<span style={{ minWidth: '80px' }}>
{visible ? word : '•••••'}
</span>
<button
type="button"
onClick={() => toggleWordVisibility(index)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary, #666)',
}}
aria-label={`${visible ? 'Masquer' : 'Afficher'} le mot ${index + 1}`}
title={`${visible ? 'Masquer' : 'Afficher'} le mot ${index + 1}`}
>
{visible ? '👁' : '👁‍🗨'}
</button>
</div>
);
})}
</div>
{qrDataUrl !== null && (
<p>
<img

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getStoredRelays, storeRelays, testRelay } from '../utils/relay';
import { getStoredRelays, storeRelays, testRelay, MAX_RELAYS } from '../utils/relay';
import type { RelayConfig } from '../types/identity';
export function RelaySettingsScreen(): JSX.Element {
@ -17,6 +17,9 @@ export function RelaySettingsScreen(): JSX.Element {
if (newEndpoint.trim() === '') {
return;
}
if (relays.length >= MAX_RELAYS) {
return;
}
const newRelay: RelayConfig = {
endpoint: newEndpoint.trim(),
priority: relays.length,
@ -102,6 +105,9 @@ export function RelaySettingsScreen(): JSX.Element {
</section>
<section aria-labelledby="add-relay">
<h2 id="add-relay">Ajouter un relais</h2>
<p>
Maximum {MAX_RELAYS} relais. Connectez-vous au maximum pour dupliquer les dépôts et les flux.
</p>
<div>
<input
type="text"
@ -109,14 +115,22 @@ export function RelaySettingsScreen(): JSX.Element {
onChange={(e) => {
setNewEndpoint(e.target.value);
}}
placeholder="http://relay.example.com:3019"
placeholder="https://relay.example.com"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAdd();
}
}}
disabled={relays.length >= MAX_RELAYS}
/>
<button onClick={handleAdd}>Ajouter</button>
<button onClick={handleAdd} disabled={relays.length >= MAX_RELAYS}>
Ajouter
</button>
{relays.length >= MAX_RELAYS && (
<span style={{ marginLeft: '0.5rem', color: 'var(--color-text-secondary, #666)' }}>
Limite de {MAX_RELAYS} relais atteinte.
</span>
)}
</div>
</section>
<div>

View File

@ -39,6 +39,7 @@ export function WordInputGrid({
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState<number>(-1);
const [visibleWords, setVisibleWords] = useState<Set<number>>(new Set());
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const suggestionsRef = useRef<HTMLDivElement | null>(null);
@ -219,7 +220,7 @@ export function WordInputGrid({
inputRefs.current[index] = el;
}}
id={`${id ?? 'word'}-${index}`}
type="password"
type={visibleWords.has(index) ? 'text' : 'password'}
value={word}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
handleInputChange(index, e.target.value);
@ -241,7 +242,7 @@ export function WordInputGrid({
style={{
width: '100%',
padding: '0.5rem',
paddingRight: '2.5rem',
paddingRight: word.length > 0 ? '2.5rem' : '0.5rem',
fontSize: '1rem',
fontFamily: 'monospace',
border: '1px solid var(--color-border, #ccc)',
@ -254,10 +255,15 @@ export function WordInputGrid({
<button
type="button"
onClick={() => {
const input = inputRefs.current[index];
if (input !== null) {
input.type = input.type === 'password' ? 'text' : 'password';
}
setVisibleWords((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
}}
style={{
position: 'absolute',
@ -271,10 +277,10 @@ export function WordInputGrid({
fontSize: '0.875rem',
color: 'var(--color-text-secondary, #666)',
}}
aria-label={`${word.length > 0 ? 'Afficher' : 'Masquer'} le mot ${index + 1}`}
title={`${word.length > 0 ? 'Afficher' : 'Masquer'} le mot ${index + 1}`}
aria-label={`${visibleWords.has(index) ? 'Masquer' : 'Afficher'} le mot ${index + 1}`}
title={`${visibleWords.has(index) ? 'Masquer' : 'Afficher'} le mot ${index + 1}`}
>
👁
{visibleWords.has(index) ? '👁' : '👁‍🗨'}
</button>
)}
</div>

View File

@ -1,4 +1,5 @@
import { useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
listenToChannel,
sendToChannel,
@ -15,6 +16,7 @@ import type { LoginProof } from '../types/identity';
import type { Contrat, Action } from '../types/contract';
export function useChannel() {
const navigate = useNavigate();
const { identity } = useIdentity();
const graphResolver = useState(() => {
return new GraphResolver();
@ -121,6 +123,29 @@ export function useChannel() {
[graphResolver],
);
const handleNavigateLogin = useCallback(
(message: ChannelMessage): void => {
if (message.type !== 'navigate-login') {
return;
}
const payload = message.payload as { service?: string; member?: string };
const service =
typeof payload?.service === 'string' ? payload.service : '';
const member =
typeof payload?.member === 'string' ? payload.member : '';
const params = new URLSearchParams();
if (service !== '') {
params.set('service', service);
}
if (member !== '') {
params.set('membre', member);
}
const qs = params.toString();
navigate(qs !== '' ? `/login?${qs}` : '/login');
},
[navigate],
);
const sendLoginProof = useCallback((proof: LoginProof): void => {
sendToChannel({
type: 'login-proof',
@ -138,11 +163,13 @@ export function useChannel() {
handleAuthRequest(message as AuthRequestMessage);
} else if (message.type === 'contract') {
handleContract(message as ContractMessage);
} else if (message.type === 'navigate-login') {
handleNavigateLogin(message);
}
});
return cleanup;
}, [handleAuthRequest, handleContract]);
}, [handleAuthRequest, handleContract, handleNavigateLogin]);
return {
sendLoginProof,

View File

@ -12,6 +12,7 @@ import type {
/**
* Update graph cache with decrypted message.
* Message must have types.types_names_chiffres (MessageBase). Skips otherwise.
*/
export function updateGraphFromMessage(
decrypted: unknown,
@ -19,7 +20,15 @@ export function updateGraphFromMessage(
): void {
try {
const msg = decrypted as MessageBase;
const typeNames = msg.types.types_names_chiffres.toLowerCase();
const raw = msg?.types?.types_names_chiffres;
if (typeof raw !== 'string' || raw.length === 0) {
console.warn(
'[SyncService] message missing types.types_names_chiffres, skipped',
{ uuid: (msg as { uuid?: string })?.uuid },
);
return;
}
const typeNames = raw.toLowerCase();
if (typeNames.includes('service')) {
graphResolver.addService(msg as unknown as Service);

View File

@ -1,13 +1,21 @@
import {
etc as secpEtc,
getPublicKey,
sign,
Signature,
utils as secpUtils,
verify,
} from '@noble/secp256k1';
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
// Require hmacSha256Sync for secp256k1 sign() (RFC6979). Set once at module load.
if (secpEtc.hmacSha256Sync === undefined) {
secpEtc.hmacSha256Sync = (k: Uint8Array, ...m: Uint8Array[]): Uint8Array =>
hmac(sha256, k, secpEtc.concatBytes(...m));
}
export interface KeyPair {
privateKey: string;
publicKey: string;

View File

@ -1,3 +1,4 @@
import { assertNoSecretsInChannelPayload } from './iframeChannel';
import type {
IframeMessage,
AuthResponseMessage,
@ -8,12 +9,14 @@ import type {
/**
* Sends a message to the parent window if running in an iframe.
* Uses postMessage API for cross-origin communication.
* Ensures no private keys or secrets leave the iframe (key isolation).
*/
export function sendMessageToParent(message: IframeMessage): void {
if (window.parent === window) {
console.warn('Not in iframe context, message not sent');
return;
}
assertNoSecretsInChannelPayload(message.payload);
window.parent.postMessage(message, '*');
}

View File

@ -5,10 +5,26 @@ import type { LoginProof } from '../types/identity';
* Messages pour la communication iframe avec Channel Messages.
*/
export interface ChannelMessage {
type: 'auth-request' | 'auth-response' | 'login-proof' | 'service-status' | 'error' | 'contract';
type:
| 'auth-request'
| 'auth-response'
| 'login-proof'
| 'service-status'
| 'error'
| 'contract'
| 'pairing-relay-status'
| 'navigate-login';
payload?: unknown;
}
export interface PairingRelayStatusMessage extends ChannelMessage {
type: 'pairing-relay-status';
payload: {
pairingSatisfied: boolean;
relayOk: boolean;
};
}
export interface AuthRequestMessage extends ChannelMessage {
type: 'auth-request';
payload: {
@ -54,14 +70,49 @@ export interface ContractMessage extends ChannelMessage {
};
}
const FORBIDDEN_PAYLOAD_KEYS = ['privateKey', 'cle_privee'] as const;
/**
* Recursively check that no secret keys (privateKey, cle_privee) are present
* in a postMessage-to-parent payload. Throws if any forbidden key is found.
* Use for any postMessage to parent (iframeChannel, iframe.ts). Key isolation.
*/
export function assertNoSecretsInChannelPayload(
value: unknown,
path = 'payload',
): void {
if (value === null || value === undefined) {
return;
}
if (typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
for (const k of Object.keys(obj)) {
if (FORBIDDEN_PAYLOAD_KEYS.includes(k as (typeof FORBIDDEN_PAYLOAD_KEYS)[number])) {
throw new Error(
`[iframeChannel] Forbidden key "${k}" in channel ${path}: no secrets may leave iframe`,
);
}
assertNoSecretsInChannelPayload(obj[k], `${path}.${k}`);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, i) =>
assertNoSecretsInChannelPayload(item, `${path}[${i}]`),
);
}
}
/**
* Send message to parent window (Channel Messages).
* Ensures no private keys or secrets leave the iframe (key isolation).
*/
export function sendToChannel(message: ChannelMessage): void {
if (window.parent === window) {
console.warn('Not in iframe context, message not sent');
return;
}
assertNoSecretsInChannelPayload(message.payload);
window.parent.postMessage(message, '*');
}

View File

@ -4,19 +4,63 @@ 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;
/** Maximum number of relays that can be configured. */
export const MAX_RELAYS = 8;
/**
* Default relay endpoint.
*/
const DEFAULT_RELAY_ENDPOINT = 'https://relay.certificator.4nkweb.com';
/**
* Get stored relay configurations.
* Initializes with default relay if none are configured.
*/
export function getStoredRelays(): RelayConfig[] {
try {
const stored = localStorage.getItem('userwallet_relays');
if (stored === null) {
return [];
// Initialize with default relay
const defaultRelays: RelayConfig[] = [
{
endpoint: DEFAULT_RELAY_ENDPOINT,
priority: 0,
enabled: true,
},
];
storeRelays(defaultRelays);
return defaultRelays;
}
return JSON.parse(stored) as RelayConfig[];
const raw = JSON.parse(stored) as RelayConfig[];
// If empty, initialize with default relay
if (raw.length === 0) {
const defaultRelays: RelayConfig[] = [
{
endpoint: DEFAULT_RELAY_ENDPOINT,
priority: 0,
enabled: true,
},
];
storeRelays(defaultRelays);
return defaultRelays;
}
const relays = raw.slice(0, MAX_RELAYS);
if (raw.length > MAX_RELAYS) {
storeRelays(relays);
}
return relays;
} catch (error) {
console.error('Error reading stored relays:', error);
return [];
// Initialize with default relay on error
const defaultRelays: RelayConfig[] = [
{
endpoint: DEFAULT_RELAY_ENDPOINT,
priority: 0,
enabled: true,
},
];
storeRelays(defaultRelays);
return defaultRelays;
}
}

5
website-data/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
*.local
.env.private
.env.backup

84
website-data/README.md Normal file
View File

@ -0,0 +1,84 @@
# website-data
Iframe dédiée à la **data** (données non clés) à `data.certificator.4nkweb.com`. Elle communique avec le **site** (parent) et avec **UserWallet** (clés, relais).
## Architecture
```
site <-> data (+notifications) <-> userwallet (sur les relais)
```
- **Site** : intègre liframe Data, envoie `contract`, `auth-request`, `data-request`, etc.
- **Data** : iframe dédiée aux données (hors clés). Relaye contrat / auth vers UserWallet ; reçoit `login-proof`, `auth-response`, etc. et les transmet au site. Émet des `notification` (ex. `userwallet:login-proof`).
- **UserWallet** : embarquée par Data, gère clés et relais (sync, signatures). Tourne sur les relais (api-relay).
Data nhéberge **aucune clé** ; toute la crypto reste dans UserWallet.
## Prérequis
- **UserWallet** : servi sur lURL configurée (voir cidessous).
## Installation
```bash
cd website-data
npm install
npm run build
```
## Développement
```bash
npm run dev
```
Ouvre par défaut sur `http://localhost:3025`. En standalone, la page indique quil faut lembedder dans un parent pour le flux site <-> data <-> userwallet.
## Configuration
- **UserWallet** : `src/config.ts`, `USERWALLET_ORIGIN`. En dev : `http://localhost:3018`. En prod : `https://userwallet.certificator.4nkweb.com`. Override : `VITE_USERWALLET_ORIGIN`.
- **Data** : `VITE_DATA_ORIGIN` pour la cible postMessage depuis le site (dev `http://localhost:3025`, prod `https://data.certificator.4nkweb.com`).
## Messages postMessage
### Site → Data
| Type | Rôle |
|------|------|
| `contract` | Contrat + contrats_fils + actions → transmis à UserWallet |
| `auth-request` | Demande dauth → transmise à UserWallet |
| `data-request` | Requête data (réponse `data-response`) |
| `ping` | Réponse `pong` |
### Data → Site
| Type | Rôle |
|------|------|
| `data-response` | Réponse à `data-request` |
| `notification` | Événements (ex. `userwallet:login-proof`, `userwallet:auth-response`, `userwallet:error`) |
| `pong` | Réponse à `ping` |
| `error` | Erreur |
| `login-proof`, `auth-response`, `service-status` | Relayés depuis UserWallet |
### Data ↔ UserWallet
Data embarque UserWallet en iframe. Elle envoie `contract`, `auth-request` et reçoit `login-proof`, `auth-response`, `error`, `service-status`, quelle relaye au site.
## Notifications
Data envoie des `notification` au site avec `payload: { kind, detail }`, notamment :
- `userwallet:login-proof`, `userwallet:auth-response`, `userwallet:error` lors des événements UserWallet.
- Un flux **relay** (non implémenté) pourra alimenter des `notification` à partir des relais (data non clés).
## Production
- **Domaine** : `data.certificator.4nkweb.com`
- **Port** : 3025 (configurable dans `vite.config.ts`).
- **Déploiement** : build `dist/`, servir via Nginx / systemd comme pour `website-skeleton`. Voir `docs/` et infra 4NK pour proxy et certificats.
## Fichiers principaux
- `src/config.ts` : origines Data et UserWallet.
- `src/channel.ts` : types et helpers postMessage (site <-> data, data <-> userwallet).
- `src/main.ts` : init, relay site/userwallet, notifications.

27
website-data/index.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Data data.certificator.4nkweb.com</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 1rem;
line-height: 1.5;
background: #f8f9fa;
}
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
.status { padding: 0.5rem; border-radius: 6px; background: #e9ecef; font-size: 0.9rem; }
.status.in-iframe { background: #d1e7dd; }
</style>
</head>
<body>
<h1>Data iframe</h1>
<p class="status" id="status">Chargement…</p>
<div id="userwallet-container" style="margin-top:1rem;"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,29 @@
#!/bin/bash
# Install and run website-data on this machine (backend port 3025).
# Usage: ./install-website-data.sh
# Then run update-proxy-nginx.sh from repo root for proxy + certificate (data.certificator.4nkweb.com).
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
echo "=== Installation website-data ==="
if [ ! -d node_modules ]; then
echo "Installation des dépendances..."
npm install
fi
echo "Build..."
npm run build
echo "Installation du service systemd..."
sudo sed "s|/srv/4NK/data.certificator.4nkweb.com|${SCRIPT_DIR}|g" website-data.service > /tmp/website-data.service
sudo cp /tmp/website-data.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable website-data
sudo systemctl start website-data
echo "=== website-data installé et démarré (port 3025) ==="
echo "Pour proxy + certificat: ./update-proxy-nginx.sh (depuis la racine du dépôt)"

1002
website-data/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
website-data/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "website-data",
"version": "1.0.0",
"description": "Data iframe (data.certificator.4nkweb.com). Non-key data; communicates with site and UserWallet (relays).",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@ -0,0 +1,99 @@
/**
* postMessage channel: Site <-> Data (+notifications), Data <-> UserWallet (relays).
* Data does not hold keys; all crypto stays in UserWallet.
*/
/** Messages from Site (parent) to Data iframe. */
export type SiteToDataMessage =
| { type: 'data-request'; payload?: { id?: string; query?: unknown } }
| { type: 'contract'; payload?: { contrat?: unknown; contrats_fils?: unknown[]; actions?: unknown[] } }
| { type: 'auth-request'; payload?: { serviceId: string } }
| { type: 'ping'; payload?: unknown };
/** Messages from Data iframe to Site (parent). */
export type DataToSiteMessage =
| { type: 'data-response'; payload: { id?: string; data?: unknown; error?: string } }
| { type: 'notification'; payload: { kind: string; detail?: unknown } }
| { type: 'pong'; payload?: unknown }
| { type: 'error'; payload: { message: string; code?: string } }
| { type: 'login-proof'; payload: unknown }
| { type: 'auth-response'; payload: unknown }
| { type: 'service-status'; payload: unknown };
/** Messages Data -> UserWallet (when Data embeds UserWallet). Forwards contract, auth-request, etc. */
export type DataToUserWalletMessage =
| { type: 'contract'; payload: { contrat?: unknown; contrats_fils?: unknown[]; actions?: unknown[] } }
| { type: 'auth-request'; payload: { serviceId: string } };
/** Messages UserWallet -> Data. login-proof, auth-response, error. */
export type UserWalletToDataMessage =
| { type: 'login-proof'; payload: unknown }
| { type: 'auth-response'; payload: unknown }
| { type: 'error'; payload: { message: string; code?: string } }
| { type: 'service-status'; payload: unknown };
export function isInIframe(): boolean {
return typeof window !== 'undefined' && window.parent !== window;
}
/**
* Send message to parent (Site). Use when Data runs as iframe.
*/
export function sendToParent<T extends DataToSiteMessage>(message: T, targetOrigin = '*'): void {
if (typeof window === 'undefined' || window.parent === window) {
return;
}
window.parent.postMessage(message, targetOrigin);
}
/**
* Listen for messages from parent (Site). Only invokes handler when event.source === window.parent.
*/
export function listenToParent(
handler: (event: MessageEvent) => void,
): () => void {
const wrapped = (event: MessageEvent): void => {
if (event.source !== window.parent || event.data?.type == null) {
return;
}
handler(event);
};
window.addEventListener('message', wrapped);
return () => window.removeEventListener('message', wrapped);
}
/**
* Send message to UserWallet iframe (when Data embeds it). Target USERWALLET_ORIGIN.
*/
export function sendToUserWallet(
message: DataToUserWalletMessage,
iframe: HTMLIFrameElement | null,
targetOrigin: string,
): void {
if (iframe?.contentWindow == null) {
return;
}
iframe.contentWindow.postMessage(message, targetOrigin);
}
/**
* Listen for messages from UserWallet iframe (Data is parent).
* Only invokes handler when event.source === userwalletIframe.contentWindow.
*/
export function listenToUserWallet(
handler: (event: MessageEvent) => void,
userwalletIframe: HTMLIFrameElement | null,
): () => void {
const wrapped = (event: MessageEvent): void => {
if (event.data?.type == null) {
return;
}
const fromUw = userwalletIframe?.contentWindow != null && event.source === userwalletIframe.contentWindow;
if (!fromUw) {
return;
}
handler(event);
};
window.addEventListener('message', wrapped);
return () => window.removeEventListener('message', wrapped);
}

View File

@ -0,0 +1,20 @@
/**
* Configuration for Data iframe (data.certificator.4nkweb.com).
* Override via env: VITE_USERWALLET_ORIGIN, VITE_DATA_ORIGIN.
*/
const env =
typeof import.meta !== 'undefined'
? (import.meta as { env?: { VITE_USERWALLET_ORIGIN?: string; VITE_DATA_ORIGIN?: string; DEV?: boolean } })
.env
: undefined;
/** UserWallet iframe origin (embedded by Data). Dev default localhost:3018. */
export const USERWALLET_ORIGIN =
env?.VITE_USERWALLET_ORIGIN ??
(env?.DEV ? 'http://localhost:3018' : 'https://userwallet.certificator.4nkweb.com');
/** Data iframe origin (for postMessage target from site). */
export const DATA_ORIGIN =
env?.VITE_DATA_ORIGIN ??
(env?.DEV ? 'http://localhost:3025' : 'https://data.certificator.4nkweb.com');

132
website-data/src/main.ts Normal file
View File

@ -0,0 +1,132 @@
import { USERWALLET_ORIGIN } from './config.js';
import {
isInIframe,
sendToParent,
listenToParent,
sendToUserWallet,
listenToUserWallet,
type SiteToDataMessage,
} from './channel.js';
const statusEl = document.getElementById('status') as HTMLParagraphElement | null;
const userwalletContainer = document.getElementById('userwallet-container') as HTMLDivElement | null;
let userwalletIframe: HTMLIFrameElement | null = null;
function setStatus(text: string, inIframe = false): void {
if (statusEl != null) {
statusEl.textContent = text;
statusEl.classList.toggle('in-iframe', inIframe);
}
}
function ensureUserWalletIframe(): HTMLIFrameElement | null {
if (userwalletIframe != null) {
return userwalletIframe;
}
if (userwalletContainer == null) {
return null;
}
const iframe = document.createElement('iframe');
iframe.id = 'userwallet';
iframe.title = 'UserWallet';
iframe.src = USERWALLET_ORIGIN;
iframe.setAttribute('style', 'width:100%;min-height:320px;border:1px solid #dee2e6;border-radius:8px;');
userwalletContainer.appendChild(iframe);
userwalletIframe = iframe;
return iframe;
}
function handleSiteMessage(msg: MessageEvent): void {
const d = msg.data as SiteToDataMessage;
if (d?.type == null) {
return;
}
if (d.type === 'ping') {
sendToParent({ type: 'pong', payload: undefined });
return;
}
if (d.type === 'data-request') {
sendToParent({
type: 'data-response',
payload: { id: (d.payload as { id?: string })?.id, data: null },
});
return;
}
if (d.type === 'contract') {
const m = d as { type: 'contract'; payload?: { contrat?: unknown; contrats_fils?: unknown[]; actions?: unknown[] } };
const payload = m.payload;
const iframe = ensureUserWalletIframe();
if (iframe != null && payload != null) {
sendToUserWallet(
{ type: 'contract', payload: { contrat: payload.contrat, contrats_fils: payload.contrats_fils ?? [], actions: payload.actions ?? [] } },
iframe,
USERWALLET_ORIGIN,
);
}
return;
}
if (d.type === 'auth-request') {
const m = d as { type: 'auth-request'; payload?: { serviceId: string } };
const payload = m.payload ?? { serviceId: '' };
const iframe = ensureUserWalletIframe();
if (iframe != null) {
sendToUserWallet({ type: 'auth-request', payload: { serviceId: payload.serviceId } }, iframe, USERWALLET_ORIGIN);
}
return;
}
}
/** Emit notification to site (e.g. relay updates, userwallet events). */
function notifySite(kind: string, detail?: unknown): void {
sendToParent({ type: 'notification', payload: { kind, detail } });
}
/* Placeholder: relay notifications. Data fetches from api-relay (non-key); UserWallet uses relays for keys/signatures. Wire relay subscription -> notifySite('relay:...', detail) when needed. */
function handleUserWalletMessage(msg: MessageEvent): void {
const d = msg.data as { type: string; payload?: unknown };
if (d?.type == null) {
return;
}
if (d.type === 'login-proof') {
sendToParent({ type: 'login-proof', payload: d.payload });
notifySite('userwallet:login-proof', d.payload);
return;
}
if (d.type === 'auth-response') {
sendToParent({ type: 'auth-response', payload: d.payload });
notifySite('userwallet:auth-response', d.payload);
return;
}
if (d.type === 'service-status') {
sendToParent({ type: 'service-status', payload: d.payload });
return;
}
if (d.type === 'error') {
const p = d.payload as { message?: string; code?: string } | undefined;
sendToParent({ type: 'error', payload: { message: p?.message ?? 'unknown', code: p?.code } });
notifySite('userwallet:error', d.payload);
return;
}
}
function init(): void {
const inIframe = isInIframe();
setStatus(
inIframe
? 'Data iframe actif. En attente de messages du site.'
: 'Data (standalone). Embed in parent for site <-> data <-> userwallet.',
inIframe,
);
listenToParent(handleSiteMessage);
ensureUserWalletIframe();
const uw = userwalletIframe;
listenToUserWallet(handleUserWalletMessage, uw);
}
init();

20
website-data/start.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
# Build and serve website-data on port 3025 (production).
# Usage: ./start.sh
# Proxy: data.certificator.4nkweb.com -> backend:3025.
set -e
cd "$(dirname "$0")"
export PORT=${PORT:-3025}
if [ ! -d node_modules ]; then
echo "Installation des dépendances..."
npm install
fi
echo "Build du frontend website-data..."
npm run build
echo "Démarrage du serveur sur le port $PORT..."
exec npx vite preview --port "$PORT" --host 0.0.0.0

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
build: {
outDir: 'dist',
rollupOptions: {
input: ['index.html'],
},
},
server: {
port: 3025,
strictPort: false,
},
preview: {
port: 3025,
host: '0.0.0.0',
allowedHosts: ['data.certificator.4nkweb.com'],
},
});

View File

@ -0,0 +1,21 @@
[Unit]
Description=4NK Website data (iframe data, non clés)
After=network.target
[Service]
Type=simple
User=ncantu
WorkingDirectory=/srv/4NK/data.certificator.4nkweb.com
Environment=PORT=3025
Environment=NODE_ENV=production
ExecStart=/bin/bash /srv/4NK/data.certificator.4nkweb.com/start.sh
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@ -69,6 +69,10 @@ Le skeleton utilise automatiquement le contrat de service skeleton au démarrage
7. **Description du contrat** : page `contrat.html` (lien depuis l'accueil) décrit le contrat de service skeleton.
8. **Description du membre** : page `membre.html` décrit le <strong>membre connecté</strong> (l'utilisateur), pas le validateur du service. Clés générées dans l'iframe (UserWallet), stockées en IndexedDB. Distinction créateur du service (wallet .env.private, jamais exposé) vs utilisateur (iframe, IndexedDB).
### Cloisonnement iframe
Le skeleton (parent) **ne signe, ne chiffre ni ne déchiffre jamais**. Il utilise uniquement `verifyLoginProof` (vérification de signatures avec clés publiques). Aucune clé privée nest reçue ni utilisée par le parent. Toutes les opérations de signature et de chiffrement restent dans liframe UserWallet. Voir `userwallet/features/userwallet-iframe-key-isolation.md`.
## Exemple d'intégration
### Envoi de contrat depuis le parent
@ -143,7 +147,7 @@ window.addEventListener('message', (event) => {
nonceCache, // Cache anti-rejeu
timestampWindowMs: 300000 // 5 minutes
});
if (result.accept) {
// Ouvrir la session utilisateur
console.log('Login accepté');
@ -171,6 +175,7 @@ Les raisons de refus possibles :
- `src/main.ts` : chargement de l'iframe, écoute `message`, envoi du contrat au clic « Se connecter », appel à `verifyLoginProof`, gestion des messages `contract`.
- `contrat.html` : page de description du contrat de service skeleton (labels, UUIDs, usage).
- `membre.html` : page de description du <strong>membre connecté</strong> (utilisateur) et de son device (Pair) ; clés iframe / IndexedDB. Distinction service (.env.private) vs utilisateur.
- `cryptographie.html` : page explicative de la cryptographie utilisée (ECDH secp256k1, AES-256-GCM, SHA-256, Schnorr, HKDF) avec workflow complet et vulgarisation.
- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS` (extraits du contrat skeleton), types `Contrat` et `Action`.
- `src/serviceContract.ts` : contrat de service skeleton avec UUID dédié, action login, et configuration de la clé publique via `VITE_SKELETON_SERVICE_PUBLIC_KEY`.
- `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`).

View File

@ -34,6 +34,11 @@
.member-list > li > em { color: #666; font-size: 0.9rem; }
.pair-list { list-style: none; margin-top: 0.5rem; padding-left: 1rem; border-left: 3px solid #007bff; }
.pair-list > li { background: #fff; padding: 0.5rem 0.75rem; border-radius: 6px; margin: 0.4rem 0; border: 1px solid #e8e8e8; }
.roles-intro { margin-bottom: 1rem; }
.role-block { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px; padding: 0.75rem 1rem; margin: 0.5rem 0; }
.role-block h4 { margin-top: 0; }
.role-block .usage, .role-block .validation { font-size: 0.9rem; color: #555; margin: 0.25rem 0; }
.role-block .usage strong, .role-block .validation strong { color: #333; }
@media (max-width: 768px) {
body { padding: 0.75rem; }
h1 { font-size: 1.25rem; }
@ -42,7 +47,7 @@
</head>
<body>
<h1>Le contrat</h1>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="technique.html">Réseau P2P</a> · <a href="cryptographie.html">Cryptographie</a></p>
<div class="highlight">
<strong>En résumé :</strong> Ce contrat définit les règles de confiance entre vous et le service.
@ -113,6 +118,21 @@
<li><strong>Vérifiable par tous</strong> — les règles du contrat sont publiques.</li>
</ul>
<h2>Infrastructure, gouvernance et preuves</h2>
<p>
L'<strong>infrastructure</strong> (relais, stockage des messages et des signatures) est <strong>décentralisée et neutre</strong> :
elle ne dépend pas d'un acteur unique.
</p>
<p>
Les <strong>membres</strong> ont une <strong>liberté de gouvernance</strong>. Certains peuvent choisir une organisation
<strong>centralisée</strong> lorsque c'est préférable (équipe, processus, conformité). D'autres restent décentralisés.
</p>
<p>
L'important est d'avoir les <strong>preuves de la bonne exécution du contrat entre les parties</strong> :
signatures, nonces, règles publiques. Ces preuves restent vérifiables quelle que soit l'organisation
(décentralisée ou centralisée) des membres.
</p>
<details>
<summary>Détails techniques du contrat</summary>
<h3>Identifiants</h3>
@ -122,10 +142,73 @@
<li>Action login UUID : <span class="meta">0ac7de59-9e81-4bdc-bd19-c07750fad48e</span></li>
<li>Validateur (membre) : <span class="meta">0e865301-362f-4951-bfbc-531b7bddf820</span></li>
</ul>
<h3>Membres par rôles</h3>
<p>Chaque rôle définit qui peut valider quoi. Un membre peut avoir plusieurs Pairs (appareils), chaque Pair possède une clé publique unique :</p>
<h3>Rôles par défaut (types de Champ)</h3>
<p class="roles-intro">
Les <strong>11 rôles</strong> cidessous existent par défaut. Chacun peut être <strong>vide</strong> (aucun membre)
ou <strong>plein</strong> (membres et pairs définis). Tous sont <strong>connus des participants</strong> ;
les conditions d'usage et de validation s'appliquent dès qu'un rôle est rempli.
</p>
<div class="role-block">
<h4>1. Partage avec les institutions</h4>
<p class="usage"><strong>Usage :</strong> Partager des données avec des institutions.</p>
<p class="validation"><strong>Validation :</strong> Conformité aux validateurs du champ ; signatures requises selon <code>membres_du_role</code>.</p>
</div>
<div class="role-block">
<h4>2. Messages au RSSI</h4>
<p class="usage"><strong>Usage :</strong> Messages au RSSI de la société responsable du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures des membres du rôle conformes aux <code>signatures_obligatoires</code>.</p>
</div>
<div class="role-block">
<h4>3. Messages au Correspondant CNIL</h4>
<p class="usage"><strong>Usage :</strong> Messages au Correspondant CNIL de la société responsable du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures des membres du rôle ; attributs CNIL requis si applicables.</p>
</div>
<div class="role-block">
<h4>4. Messages au Responsable cybersécurité</h4>
<p class="usage"><strong>Usage :</strong> Messages au Responsable cybersécurité de la société responsable du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures conformes aux validateurs du champ.</p>
</div>
<div class="role-block">
<h4>5. Messages de support infogérant</h4>
<p class="usage"><strong>Usage :</strong> Support infogérant du service (peut inclure un membre du miner pour la gestion des clés API).</p>
<p class="validation"><strong>Validation :</strong> Signatures des membres du rôle ; <code>membre_miner_uuid</code> optionnel dans <code>datajson</code> si applicable.</p>
</div>
<div class="role-block">
<h4>6. Messages de support administrateur système</h4>
<p class="usage"><strong>Usage :</strong> Support administrateur système du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures conformes aux validateurs du champ.</p>
</div>
<div class="role-block">
<h4>7. Messages de support niveau 1</h4>
<p class="usage"><strong>Usage :</strong> Support niveau 1 du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures conformes aux validateurs du champ.</p>
</div>
<div class="role-block">
<h4>8. Messages de support niveau 2</h4>
<p class="usage"><strong>Usage :</strong> Support niveau 2 du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures conformes aux validateurs du champ.</p>
</div>
<div class="role-block">
<h4>9. Messages de support niveau 3</h4>
<p class="usage"><strong>Usage :</strong> Support niveau 3 du service.</p>
<p class="validation"><strong>Validation :</strong> Signatures conformes aux validateurs du champ.</p>
</div>
<div class="role-block">
<h4>10. Validation (contrat)</h4>
<p class="usage"><strong>Usage :</strong> Valider le contrat ; définir qui peut signer pour le contrat (<code>contrat.validateurs.membres_du_role</code>).</p>
<p class="validation"><strong>Validation :</strong> Au moins une signature valide par membre du rôle ; clés publiques autorisées dans <code>signatures_obligatoires</code>.</p>
</div>
<div class="role-block">
<h4>11. Validation du login</h4>
<p class="usage"><strong>Usage :</strong> Valider le login ; définir qui peut signer pour l'action login (<code>action.validateurs_action.membres_du_role</code>).</p>
<p class="validation"><strong>Validation :</strong> Preuve de login (hash, nonce) signée par les pairs des membres du rôle ; nonce unique ; clés autorisées.</p>
</div>
<h3>Membres par rôles (ce contrat)</h3>
<p>Ce contrat skeleton remplit uniquement les rôles <strong>Validation (contrat)</strong> et <strong>Validation du login</strong>. Les autres rôles sont vides mais connus.</p>
<h4>Validateur (contrat)</h4>
<h4>Validation (contrat)</h4>
<ul class="member-list">
<li>
<strong>Membre</strong> : <span class="meta">0e865301-362f-4951-bfbc-531b7bddf820</span>
@ -139,7 +222,7 @@
</li>
</ul>
<h4>Validateur login (action)</h4>
<h4>Validation du login</h4>
<ul class="member-list">
<li>
<strong>Membre</strong> : <span class="meta">0e865301-362f-4951-bfbc-531b7bddf820</span>
@ -154,6 +237,6 @@
</ul>
</details>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="technique.html">Réseau P2P</a> · <a href="cryptographie.html">Cryptographie</a></p>
</body>
</html>

View File

@ -0,0 +1,448 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cryptographie expliquée 4NK un nouveau web</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
line-height: 1.6;
color: #333;
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #222; }
h2 { font-size: 1.2rem; margin-top: 1.5rem; margin-bottom: 0.5rem; color: #333; }
h3 { font-size: 1rem; margin-top: 1rem; margin-bottom: 0.35rem; font-weight: 600; }
p { margin: 0.75rem 0; }
ul, ol { margin: 0.5rem 0; padding-left: 1.5rem; }
li { margin: 0.4rem 0; }
a { color: #007bff; }
a:hover { text-decoration: underline; }
.highlight { background: #e8f4fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #007bff; margin: 1rem 0; }
.info { background: #d4edda; padding: 1rem; border-radius: 8px; border-left: 4px solid #28a745; margin: 1rem 0; }
.warning { background: #fff3cd; padding: 1rem; border-radius: 8px; border-left: 4px solid #ffc107; margin: 1rem 0; }
.algo { background: #f8f9fa; padding: 1rem; border-radius: 8px; border: 1px solid #e0e0e0; margin: 1rem 0; }
.algo-title { font-weight: 700; color: #007bff; margin-bottom: 0.5rem; }
details { margin: 1rem 0; }
summary { cursor: pointer; font-weight: 600; color: #555; }
.meta { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #666; background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 4px; word-break: break-all; }
.workflow { background: #f5f5f5; padding: 1rem; border-radius: 8px; margin: 1rem 0; font-family: ui-monospace, monospace; font-size: 0.85rem; white-space: pre-wrap; line-height: 1.4; overflow-x: auto; }
.step { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin: 0.75rem 0; }
.step-num { display: inline-block; width: 28px; height: 28px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 50%; text-align: center; line-height: 28px; font-weight: 700; margin-right: 0.75rem; font-size: 0.9rem; }
.algo-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
.algo-table th, .algo-table td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }
.algo-table th { background: #f5f5f5; font-weight: 600; }
.algo-table code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
@media (max-width: 768px) {
body { padding: 0.75rem; }
h1 { font-size: 1.25rem; }
.workflow { font-size: 0.75rem; padding: 0.75rem; }
.algo-table { font-size: 0.9rem; }
}
</style>
</head>
<body>
<h1>Cryptographie expliquée</h1>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="technique.html">Réseau P2P</a></p>
<div class="highlight">
<strong>En résumé :</strong> Vos messages sont protégés par plusieurs couches de cryptographie.
Seul le destinataire peut les lire (chiffrement), et il peut vérifier que c'est bien vous qui les avez envoyés (signature).
Tout cela sans que personne ne connaisse vos clés secrètes.
</div>
<h2>Comment ça marche en simple ?</h2>
<p>
Imaginez que vous voulez envoyer une lettre secrète à un ami. Dans le monde réel, vous pourriez :
</p>
<ol>
<li><strong>Mettre la lettre dans une enveloppe fermée</strong> (chiffrement) — seul celui qui a la clé peut l'ouvrir</li>
<li><strong>Signer l'enveloppe</strong> (signature) — pour prouver que c'est bien vous qui l'avez envoyée</li>
<li><strong>Donner la clé au destinataire</strong> (échange de clé) — mais sans que personne d'autre ne puisse l'intercepter</li>
</ol>
<p>
C'est exactement ce que fait la cryptographie numérique, mais de façon <strong>mathématiquement impossible à falsifier</strong>.
</p>
<h2>Les algorithmes utilisés</h2>
<p>
Voici les « outils » cryptographiques du système. Chacun a un rôle précis :
</p>
<table class="algo-table">
<thead>
<tr>
<th>Algorithme</th>
<th>Rôle</th>
<th>Analogie</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>SHA-256</code></td>
<td>Empreinte unique du message</td>
<td>Comme une empreinte digitale : unique et impossible à inverser</td>
</tr>
<tr>
<td><code>ECDH secp256k1</code></td>
<td>Échange de clé sécurisé</td>
<td>Comme mélanger deux couleurs en public pour créer un secret commun</td>
</tr>
<tr>
<td><code>HKDF-SHA256</code></td>
<td>Dérivation de clé</td>
<td>Transformer le secret partagé en une clé utilisable</td>
</tr>
<tr>
<td><code>AES-256-GCM</code></td>
<td>Chiffrement du message</td>
<td>Un coffre-fort numérique ultra-sécurisé</td>
</tr>
<tr>
<td><code>Schnorr secp256k1</code></td>
<td>Signature numérique</td>
<td>Votre signature manuscrite, mais impossible à falsifier</td>
</tr>
</tbody>
</table>
<h2>Détail de chaque algorithme</h2>
<div class="algo">
<div class="algo-title">SHA-256 — L'empreinte digitale</div>
<p>
SHA-256 crée une « empreinte » unique de 64 caractères hexadécimaux (256 bits) pour n'importe quel message.
Deux messages différents auront toujours des empreintes différentes.
</p>
<ul>
<li><strong>Entrée</strong> : n'importe quel texte ou données</li>
<li><strong>Sortie</strong> : 64 caractères hexadécimaux (ex: <span class="meta">a7f3c9...</span>)</li>
<li><strong>Propriété clé</strong> : impossible de retrouver le message à partir de l'empreinte</li>
</ul>
<p><em>Usage</em> : identifier chaque message de façon unique sur le relais.</p>
</div>
<div class="algo">
<div class="algo-title">ECDH secp256k1 — Le secret partagé</div>
<p>
ECDH (Elliptic Curve Diffie-Hellman) permet à deux personnes de créer un <strong>secret commun</strong>
sans jamais l'échanger directement. C'est comme de la magie mathématique !
</p>
<div class="info">
<strong>L'analogie des couleurs :</strong> Imaginez qu'Alice et Bob veulent créer une couleur secrète.
<ul>
<li>Alice mélange une couleur publique avec sa couleur secrète → obtient « couleur A »</li>
<li>Bob mélange la même couleur publique avec sa couleur secrète → obtient « couleur B »</li>
<li>Ils échangent « couleur A » et « couleur B » (publiquement, tout le monde peut voir)</li>
<li>Alice mélange « couleur B » + sa couleur secrète → obtient la couleur finale</li>
<li>Bob mélange « couleur A » + sa couleur secrète → obtient <strong>la même couleur finale</strong></li>
</ul>
Résultat : Alice et Bob ont la même couleur secrète, sans l'avoir jamais échangée !
Un espion qui a vu « couleur A » et « couleur B » ne peut pas retrouver la couleur finale.
</div>
<ul>
<li><strong>Courbe</strong> : secp256k1 (la même que Bitcoin)</li>
<li><strong>Entrée</strong> : votre clé privée + la clé publique du destinataire</li>
<li><strong>Sortie</strong> : un secret partagé (32 octets)</li>
</ul>
<p><em>Usage</em> : créer un secret pour chiffrer les messages entre deux personnes.</p>
</div>
<div class="algo">
<div class="algo-title">HKDF-SHA256 — Le raffineur de clé</div>
<p>
HKDF (HMAC-based Key Derivation Function) transforme le secret partagé ECDH en une clé
de chiffrement de haute qualité.
</p>
<ul>
<li><strong>Entrée</strong> : le secret partagé ECDH (32 octets)</li>
<li><strong>Sortie</strong> : une clé AES-256 (32 octets) de qualité cryptographique</li>
<li><strong>Pourquoi</strong> : s'assurer que la clé est uniformément aléatoire</li>
</ul>
</div>
<div class="algo">
<div class="algo-title">AES-256-GCM — Le coffre-fort</div>
<p>
AES-256-GCM est l'algorithme de chiffrement qui protège le contenu du message.
Il offre à la fois la <strong>confidentialité</strong> (personne ne peut lire) et
l'<strong>intégrité</strong> (personne ne peut modifier sans qu'on le sache).
</p>
<ul>
<li><strong>Taille de clé</strong> : 256 bits (très sécurisé)</li>
<li><strong>Mode</strong> : GCM (Galois/Counter Mode) avec authentification</li>
<li><strong>IV</strong> : vecteur d'initialisation de 12 octets (différent à chaque chiffrement)</li>
<li><strong>Tag</strong> : code d'authentification de 16 octets (détecte les modifications)</li>
</ul>
<p><em>Usage</em> : chiffrer le message pour que seul le destinataire puisse le lire.</p>
</div>
<div class="algo">
<div class="algo-title">Schnorr secp256k1 — La signature</div>
<p>
La signature Schnorr prouve que vous êtes bien l'auteur du message, sans révéler votre clé privée.
Elle est <strong>plus efficace</strong> et <strong>plus simple</strong> que les signatures ECDSA traditionnelles.
</p>
<ul>
<li><strong>Entrée</strong> : le hash du message + votre clé privée</li>
<li><strong>Sortie</strong> : une signature de 64 octets (128 caractères hex)</li>
<li><strong>Vérification</strong> : n'importe qui peut vérifier avec votre clé publique</li>
</ul>
<div class="warning">
<strong>Important :</strong> La signature prouve que vous avez signé, mais elle ne révèle
jamais votre clé privée. Même en voyant 1000 de vos signatures, personne ne peut
retrouver votre clé secrète.
</div>
</div>
<h2>Le workflow complet</h2>
<p>
Voici ce qui se passe quand vous envoyez un message sécurisé, étape par étape :
</p>
<h3>Phase 1 : Préparation</h3>
<div class="step">
<span class="step-num">1</span>
<strong>Création du message</strong><br>
Votre message est structuré avec : les données, un horodatage, un identifiant unique (UUID), et les règles de validation.
</div>
<div class="step">
<span class="step-num">2</span>
<strong>Calcul de l'empreinte (hash)</strong><br>
<span class="meta">Message → SHA-256 → Hash (64 caractères)</span><br>
Cette empreinte unique identifie le message sur le réseau.
</div>
<div class="step">
<span class="step-num">3</span>
<strong>Génération du nonce</strong><br>
Un nombre aléatoire unique (nonce) est créé pour éviter qu'un même message soit rejoué.
</div>
<h3>Phase 2 : Chiffrement</h3>
<div class="step">
<span class="step-num">4</span>
<strong>Échange de clé ECDH</strong><br>
<span class="meta">Votre clé privée + Clé publique du destinataire → ECDH → Secret partagé</span><br>
Un secret commun est calculé mathématiquement, sans jamais être transmis.
</div>
<div class="step">
<span class="step-num">5</span>
<strong>Dérivation de la clé AES</strong><br>
<span class="meta">Secret partagé → HKDF-SHA256 → Clé AES-256</span><br>
Le secret est transformé en une clé de chiffrement de qualité.
</div>
<div class="step">
<span class="step-num">6</span>
<strong>Chiffrement du message</strong><br>
<span class="meta">Clé AES + IV aléatoire + Message → AES-256-GCM → Message chiffré + Tag</span><br>
Le message est enfermé dans un « coffre-fort » numérique.
</div>
<h3>Phase 3 : Signature</h3>
<div class="step">
<span class="step-num">7</span>
<strong>Signature Schnorr</strong><br>
<span class="meta">Hash + Nonce + Votre clé privée → Schnorr → Signature</span><br>
Vous signez l'empreinte du message avec votre clé privée.
</div>
<h3>Phase 4 : Publication sur le relais</h3>
<div class="step">
<span class="step-num">8</span>
<strong>Envoi au relais</strong><br>
Trois éléments sont envoyés séparément :
<ul style="margin-top: 0.5rem;">
<li><strong>MsgChiffre</strong> : le message chiffré + son hash</li>
<li><strong>MsgCle</strong> : l'IV + votre clé publique (pour que le destinataire puisse déchiffrer)</li>
<li><strong>MsgSignature</strong> : votre signature + clé publique</li>
</ul>
</div>
<h3>Phase 5 : Collecte des signatures (multi-signature)</h3>
<div class="step">
<span class="step-num">9</span>
<strong>Attente des co-signataires</strong><br>
Si le contrat exige plusieurs signatures (ex: 2 appareils sur 3), le système attend que les autres signent :
<ul style="margin-top: 0.5rem;">
<li>Interrogation du relais toutes les 2 secondes</li>
<li>Timeout après 5 minutes si signatures manquantes</li>
<li>Progression affichée (ex: "2/3 signatures")</li>
</ul>
</div>
<div class="step">
<span class="step-num">10</span>
<strong>Validation</strong><br>
Le message est validé quand :
<ul style="margin-top: 0.5rem;">
<li>Toutes les signatures requises sont présentes (cardinalité)</li>
<li>Les dépendances sont respectées (ex: "A doit signer avant B")</li>
<li>Les clés publiques correspondent aux signataires autorisés</li>
</ul>
</div>
<h3>Phase 6 : Réception et déchiffrement</h3>
<div class="step">
<span class="step-num">11</span>
<strong>Scan des clés</strong><br>
Le destinataire interroge le relais pour récupérer les MsgCle récentes.
</div>
<div class="step">
<span class="step-num">12</span>
<strong>Récupération du message</strong><br>
Avec le hash trouvé dans la MsgCle, il récupère le message chiffré.
</div>
<div class="step">
<span class="step-num">13</span>
<strong>Déchiffrement ECDH inverse</strong><br>
<span class="meta">Sa clé privée + Clé publique de l'émetteur (df) → ECDH → Même secret partagé</span><br>
Il recalcule le même secret, puis déchiffre avec AES-GCM.
</div>
<div class="step">
<span class="step-num">14</span>
<strong>Vérification des signatures</strong><br>
Chaque signature est vérifiée avec la clé publique du signataire.
</div>
<h2>Schéma récapitulatif</h2>
<div class="workflow">┌─────────────────────────────────────────────────────────────────┐
│ ÉMETTEUR │
├─────────────────────────────────────────────────────────────────┤
│ Message → SHA-256 → Hash │
│ │
│ Clé privée émetteur ─┐ │
│ ├──→ ECDH → Secret partagé │
│ Clé publique dest. ──┘ │
│ │
│ Secret partagé → HKDF-SHA256 → Clé AES-256 │
│ │
│ Clé AES + IV + Message → AES-256-GCM → Message chiffré │
│ │
│ Hash + Nonce + Clé privée → Schnorr → Signature │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ RELAIS │
├─────────────────────────────────────────────────────────────────┤
│ POST /messages ← MsgChiffre (hash, message_chiffre) │
│ POST /keys ← MsgCle (hash, iv, df_ecdh = clé pub émett.) │
│ POST /signatures← MsgSignature (hash, signature, clé publique) │
│ │
│ ... attente des co-signatures (GET /signatures/:hash) ... │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ DESTINATAIRE │
├─────────────────────────────────────────────────────────────────┤
│ GET /keys?start=&end= → Liste des MsgCle récentes │
│ GET /messages/:hash → MsgChiffre │
│ GET /signatures/:hash → MsgSignature[] │
│ │
│ Clé privée dest. ────┐ │
│ ├──→ ECDH → Même secret partagé │
│ Clé publique émett. ─┘ (df_ecdh_scannable) │
│ │
│ Secret partagé → HKDF-SHA256 → Clé AES-256 │
│ │
│ Clé AES + IV + Ciphertext → AES-256-GCM → Message original │
│ │
│ Vérification : Signature + Clé publique → Schnorr verify → OK │
└─────────────────────────────────────────────────────────────────┘</div>
<h2>Pourquoi c'est sécurisé ?</h2>
<h3>Confidentialité</h3>
<ul>
<li><strong>Seul le destinataire peut lire</strong> : le secret partagé ECDH ne peut être calculé que par l'émetteur et le destinataire</li>
<li><strong>AES-256</strong> : considéré incassable avec les technologies actuelles (2²⁵⁶ combinaisons possibles)</li>
<li><strong>IV unique</strong> : même si vous envoyez le même message deux fois, le résultat chiffré sera différent</li>
</ul>
<h3>Intégrité</h3>
<ul>
<li><strong>GCM</strong> : le mode Galois/Counter détecte toute modification du message chiffré</li>
<li><strong>Hash</strong> : l'empreinte SHA-256 garantit que le message n'a pas été altéré</li>
</ul>
<h3>Authenticité</h3>
<ul>
<li><strong>Signature Schnorr</strong> : prouve mathématiquement que c'est bien vous qui avez signé</li>
<li><strong>Multi-signature</strong> : plusieurs appareils peuvent être requis pour valider</li>
<li><strong>Anti-rejeu</strong> : le nonce empêche de réutiliser une ancienne signature</li>
</ul>
<h3>Séparation des données</h3>
<ul>
<li><strong>Message, clé et signature séparés</strong> : même si quelqu'un intercepte un élément, il ne peut rien faire sans les autres</li>
<li><strong>Clé privée jamais transmise</strong> : seules les clés publiques et les signatures circulent</li>
</ul>
<details>
<summary>Détails techniques pour les développeurs</summary>
<h3>Paramètres cryptographiques</h3>
<table class="algo-table">
<tr><th>Paramètre</th><th>Valeur</th></tr>
<tr><td>Courbe elliptique</td><td>secp256k1 (256 bits)</td></tr>
<tr><td>Taille clé AES</td><td>256 bits</td></tr>
<tr><td>Taille IV (AES-GCM)</td><td>96 bits (12 octets)</td></tr>
<tr><td>Taille Tag (AES-GCM)</td><td>128 bits (16 octets)</td></tr>
<tr><td>Hash</td><td>SHA-256 (256 bits)</td></tr>
<tr><td>KDF</td><td>HKDF-SHA256</td></tr>
<tr><td>Signature</td><td>Schnorr secp256k1 (64 octets)</td></tr>
</table>
<h3>Bibliothèques utilisées</h3>
<ul>
<li><code>@noble/secp256k1</code> : ECDH, Schnorr, manipulation de clés</li>
<li><code>@noble/hashes</code> : SHA-256, HKDF</li>
<li><code>Web Crypto API</code> : AES-256-GCM (navigateur)</li>
</ul>
<h3>Format des clés</h3>
<ul>
<li><strong>Clé privée</strong> : 32 octets (64 caractères hex)</li>
<li><strong>Clé publique (compressée)</strong> : 33 octets (66 caractères hex, préfixe 02 ou 03)</li>
<li><strong>Signature Schnorr</strong> : 64 octets (128 caractères hex)</li>
</ul>
<h3>Fichiers source</h3>
<ul>
<li><code>userwallet/src/utils/encryption.ts</code> : encryptWithECDH, decryptWithECDH</li>
<li><code>userwallet/src/utils/crypto.ts</code> : signMessage, verifySignature, generateChallenge</li>
<li><code>userwallet/src/utils/relay.ts</code> : postMessageChiffre, postSignature, postKey</li>
<li><code>userwallet/src/utils/collectSignatures.ts</code> : runCollectLoop, fetchSignaturesForHash</li>
<li><code>userwallet/src/utils/loginValidation.ts</code> : hasEnoughSignatures, checkDependenciesSatisfied</li>
<li><code>service-login-verify/</code> : verifyLoginProof (côté parent)</li>
</ul>
<h3>Collecte des signatures</h3>
<table class="algo-table">
<tr><th>Constante</th><th>Valeur</th><th>Description</th></tr>
<tr><td>COLLECT_POLL_MS</td><td>2 000 ms</td><td>Intervalle entre chaque interrogation</td></tr>
<tr><td>COLLECT_TIMEOUT_MS</td><td>300 000 ms</td><td>Timeout global (5 minutes)</td></tr>
<tr><td>COLLECT_FETCH_TIMEOUT_MS</td><td>15 000 ms</td><td>Timeout par requête</td></tr>
</table>
</details>
<h2>En résumé</h2>
<div class="info">
<strong>Le système combine plusieurs couches de protection :</strong>
<ol>
<li><strong>ECDH</strong> pour créer un secret sans jamais l'échanger</li>
<li><strong>AES-256-GCM</strong> pour chiffrer le message</li>
<li><strong>Schnorr</strong> pour prouver votre identité</li>
<li><strong>SHA-256</strong> pour identifier chaque message de façon unique</li>
<li><strong>Multi-signature</strong> pour exiger plusieurs validations</li>
</ol>
Résultat : vos messages sont <strong>confidentiels</strong>, <strong>authentiques</strong> et <strong>infalsifiables</strong>.
</div>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="technique.html">Réseau P2P</a></p>
</body>
</html>

View File

@ -103,12 +103,51 @@
background: #fafafa;
}
#connected-section {
padding: 1.5rem;
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 6px;
padding: 0;
margin: 1rem 0;
}
.connected-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
margin-bottom: 1rem;
border-bottom: 1px solid #e0e0e0;
}
.connected-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.25rem;
flex-shrink: 0;
}
.connected-notifications {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
border: 1px solid #ccc;
border-radius: 8px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.connected-notifications:hover {
background: #f5f5f5;
border-color: #999;
}
.connected-notifications svg {
width: 24px;
height: 24px;
}
#user-info {
margin: 1rem 0;
padding: 1rem;
@ -155,7 +194,8 @@
<h1>4NK un nouveau web - site d'exemple</h1>
<div id="login-section">
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="technique.html">Réseau P2P</a> · <a href="cryptographie.html">Cryptographie</a></p>
<p id="waiting-status" style="display: none;" role="status" aria-live="polite">Vérification du statut pairing et relais…</p>
<div class="button-group">
<button type="button" id="btn-login" class="primary">Se connecter</button>
</div>
@ -170,9 +210,18 @@
</div>
<div id="connected-section" style="display: none;">
<header class="connected-header" aria-label="Zone connectée">
<div class="connected-avatar" aria-hidden="true" title="Avatar">👤</div>
<button type="button" class="connected-notifications" aria-label="Notifications" title="Notifications">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
</button>
</header>
<h2>Vous êtes connecté</h2>
<div id="user-info"></div>
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="technique.html">Réseau P2P</a> · <a href="cryptographie.html">Cryptographie</a></p>
<div class="button-group">
<button type="button" id="btn-logout" class="danger">Se déconnecter</button>
</div>

View File

@ -37,7 +37,7 @@
</head>
<body>
<h1>Qui êtes-vous ?</h1>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Voir le contrat</a></p>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Voir le contrat</a> · <a href="technique.html">Réseau P2P</a> · <a href="cryptographie.html">Cryptographie</a></p>
<div class="highlight">
<strong>En résumé :</strong> Vous êtes un <strong>membre</strong> qui peut avoir plusieurs appareils (Pairs).
@ -177,6 +177,6 @@
})();
</script>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Voir le contrat</a></p>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Voir le contrat</a> · <a href="technique.html">Réseau P2P</a> · <a href="cryptographie.html">Cryptographie</a></p>
</body>
</html>

View File

@ -18,14 +18,14 @@ import type { Validateurs } from 'service-login-verify';
*/
function getDefaultValidateurs(): Validateurs {
const contractData = getSkeletonServiceContractData();
const loginAction = contractData.actions.find((a) =>
const loginAction = contractData.actions.find((a) =>
a.datajson?.types_names_chiffres?.includes('login')
);
if (loginAction?.validateurs_action !== undefined) {
return loginAction.validateurs_action;
}
// Fallback to contract validators if no action found
return contractData.contrat.validateurs;
}
@ -35,9 +35,12 @@ export const DEFAULT_VALIDATEURS = getDefaultValidateurs();
/**
* Contract structure (matches userwallet types).
* version and types required for UserWallet iframe (MessageBase/MessageAValider).
*/
export interface Contrat {
uuid: string;
version?: string;
types?: { types_uuid: string[]; types_names_chiffres: string };
validateurs: {
membres_du_role: Array<{
membre_uuid: string;
@ -57,9 +60,12 @@ export interface Contrat {
/**
* Action structure (matches userwallet types).
* version and types required for UserWallet iframe (MessageBase/MessageAValider).
*/
export interface Action {
uuid: string;
version?: string;
types?: { types_uuid: string[]; types_names_chiffres: string };
validateurs_action: {
membres_du_role: Array<{
membre_uuid: string;

View File

@ -20,11 +20,21 @@ const iframeContainer = document.getElementById('iframe-container') as HTMLDivEl
const userInfo = document.getElementById('user-info') as HTMLDivElement;
const loginSection = document.getElementById('login-section') as HTMLDivElement;
const connectedSection = document.getElementById('connected-section') as HTMLDivElement;
const waitingStatusEl = document.getElementById('waiting-status') as HTMLParagraphElement | null;
const nonceCache = new NonceCache(3600000);
let currentValidateurs: Validateurs = DEFAULT_VALIDATEURS as Validateurs;
let allowedPubkeys = buildAllowedPubkeysFromValidateurs(currentValidateurs);
/** Last pairing/relay status from iframe. Connected section is shown only when both are satisfied. */
let pairingRelayStatus: { pairingSatisfied: boolean; relayOk: boolean } | null =
null;
/** True when user clicked "Se connecter"; we send navigate-login once iframe is ready. */
let loginRequested = false;
/** True when iframe has fired load; we can send contract / navigate-login. */
let iframeLoaded = false;
// Store received contract to send to iframe
// Initialize with skeleton service contract
const skeletonContractData = getSkeletonServiceContractData();
@ -68,7 +78,23 @@ function isLoggedIn(): boolean {
return getSession() !== null;
}
/** True when user can access connected section: logged in + pairing satisfied + relay OK. */
function canShowConnectedSection(): boolean {
if (!isLoggedIn() || pairingRelayStatus === null) {
return false;
}
return pairingRelayStatus.pairingSatisfied && pairingRelayStatus.relayOk;
}
function showLoginInterface(): void {
showLoginInterfaceWithIframe(false);
}
/**
* Show login section. When waitingForStatus is true, also show iframe so it loads
* and can send pairing-relay-status (needed when user has session but refreshed page).
*/
function showLoginInterfaceWithIframe(waitingForStatus: boolean): void {
if (loginSection !== null) {
loginSection.style.display = 'block';
}
@ -76,7 +102,10 @@ function showLoginInterface(): void {
connectedSection.style.display = 'none';
}
if (iframeContainer !== null) {
iframeContainer.style.display = 'none';
iframeContainer.style.display = waitingForStatus ? 'block' : 'none';
}
if (waitingStatusEl !== null) {
waitingStatusEl.style.display = waitingForStatus ? 'block' : 'none';
}
}
@ -93,22 +122,41 @@ function showConnectedInterface(): void {
}
function updateUI(): void {
if (isLoggedIn()) {
if (canShowConnectedSection()) {
console.warn('[skeleton] showing connected section');
showConnectedInterface();
const session = getSession();
if (session !== null && userInfo !== null) {
const publicKeys = session.proof.signatures.map((sig) => sig.cle_publique.substring(0, 16) + '...').join(', ');
const publicKeys = session.proof.signatures
.map((sig) => sig.cle_publique.substring(0, 16) + '...')
.join(', ');
userInfo.textContent = `Connecté - Clés publiques: ${publicKeys}`;
}
} else if (isLoggedIn() && pairingRelayStatus === null) {
showLoginInterfaceWithIframe(true);
} else {
showLoginInterface();
}
}
/**
* Service and member UUIDs from current contract (for navigate-login).
*/
function getLoginContext(): { service: string; member: string } | null {
const contrat = storedContract ?? skeletonContractData.contrat;
if (contrat === null) {
return null;
}
const firstMember = contrat.validateurs?.membres_du_role?.[0];
const member = firstMember?.membre_uuid ?? '';
return { service: contrat.uuid, member };
}
function sendContractToIframe(): void {
if (iframe?.contentWindow == null || storedContract === null) {
return;
}
const ctx = getLoginContext();
iframe.contentWindow.postMessage(
{
type: 'contract',
@ -116,20 +164,50 @@ function sendContractToIframe(): void {
contrat: storedContract,
contrats_fils: storedContratsFils,
actions: storedActions,
serviceUuid: ctx?.service,
membreUuid: ctx?.member ?? undefined,
},
},
USERWALLET_ORIGIN,
);
}
function handleLogin(): void {
if (iframe?.contentWindow == null) {
function sendNavigateLoginToIframe(): void {
const ctx = getLoginContext();
if (ctx === null || iframe?.contentWindow == null) {
return;
}
iframe.contentWindow.postMessage(
{
type: 'navigate-login',
payload: { service: ctx.service, member: ctx.member },
},
USERWALLET_ORIGIN,
);
}
function onIframeLoad(): void {
iframeLoaded = true;
sendContractToIframe();
if (loginRequested) {
sendNavigateLoginToIframe();
loginRequested = false;
}
}
function handleLogin(): void {
if (iframe === null) {
return;
}
loginRequested = true;
if (iframeContainer !== null) {
iframeContainer.style.display = 'block';
}
if (iframeLoaded) {
sendContractToIframe();
sendNavigateLoginToIframe();
loginRequested = false;
}
}
function handleLogout(): void {
@ -161,6 +239,19 @@ function updateValidatorsFromContract(
function handleMessage(msg: MessageEvent): void {
const d = msg.data;
if (msg.origin !== USERWALLET_ORIGIN) {
const type = d?.type as string | undefined;
if (type === 'pairing-relay-status' || type === 'login-proof') {
console.warn(
'[skeleton] message ignored: origin mismatch',
'received',
msg.origin,
'expected',
USERWALLET_ORIGIN,
);
}
return;
}
if (d?.type === undefined) {
return;
}
@ -202,6 +293,31 @@ function handleMessage(msg: MessageEvent): void {
});
if (result.accept) {
setSession(proof);
console.warn('[skeleton] login-proof received, session set');
updateUI();
} else {
console.warn('[skeleton] login-proof rejected:', result.reason);
}
return;
}
if (d.type === 'pairing-relay-status') {
const payload = d.payload as { pairingSatisfied?: boolean; relayOk?: boolean };
if (
typeof payload?.pairingSatisfied === 'boolean' &&
typeof payload?.relayOk === 'boolean'
) {
pairingRelayStatus = {
pairingSatisfied: payload.pairingSatisfied,
relayOk: payload.relayOk,
};
console.warn(
'[skeleton] pairing-relay-status received:',
'pairingSatisfied=',
payload.pairingSatisfied,
'relayOk=',
payload.relayOk,
);
updateUI();
}
return;
@ -217,6 +333,7 @@ function init(): void {
return;
}
iframe.src = USERWALLET_ORIGIN;
iframe.addEventListener('load', onIframeLoad);
btnLogin?.addEventListener('click', handleLogin);
btnLogout?.addEventListener('click', handleLogout);
window.addEventListener('message', handleMessage);
@ -231,10 +348,6 @@ function init(): void {
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
}
iframe.addEventListener('load', () => {
sendContractToIframe();
});
updateUI();
}

View File

@ -37,10 +37,16 @@ const SKELETON_ACTION_TYPE_UUID = 'fdb638c2-357a-4da2-bda1-966c6ca0de9d';
/**
* Get skeleton service contract with current public key.
* Includes version and types for MessageBase/MessageAValider (UserWallet iframe).
*/
function getSkeletonServiceContract(): Contrat {
return {
uuid: SKELETON_CONTRACT_UUID,
version: '1.0',
types: {
types_uuid: [SKELETON_CONTRACT_TYPE_UUID],
types_names_chiffres: 'contrat',
},
validateurs: {
membres_du_role: [
{
@ -60,16 +66,23 @@ function getSkeletonServiceContract(): Contrat {
services_uuid: [SKELETON_SERVICE_UUID],
types_uuid: [SKELETON_CONTRACT_TYPE_UUID],
label: 'Contrat de service Website Skeleton',
role: 'validation (contrat)',
},
};
}
/**
* Get skeleton login action with current public key.
* Includes version and types for MessageBase/MessageAValider (UserWallet iframe).
*/
function getSkeletonLoginAction(): Action {
return {
uuid: SKELETON_LOGIN_ACTION_UUID,
version: '1.0',
types: {
types_uuid: [SKELETON_ACTION_TYPE_UUID],
types_names_chiffres: 'action,login',
},
validateurs_action: {
membres_du_role: [
{
@ -89,6 +102,7 @@ function getSkeletonLoginAction(): Action {
services_uuid: [SKELETON_SERVICE_UUID],
types_uuid: [SKELETON_ACTION_TYPE_UUID],
label: 'Action login Website Skeleton',
role: 'validation du login',
},
};
}
@ -96,17 +110,17 @@ function getSkeletonLoginAction(): Action {
/**
* Get skeleton service public key from environment.
* The public key must be a valid secp256k1 compressed public key (66 hex chars: 02/03 + 64 hex).
*
*
* To configure: set VITE_SKELETON_SERVICE_PUBLIC_KEY environment variable.
* Example: VITE_SKELETON_SERVICE_PUBLIC_KEY=02abc123... npm run dev
*
*
* If not configured, returns a placeholder and logs a warning.
* The placeholder will not work for real signature verification.
*/
function getSkeletonPublicKey(): string {
const env = typeof import.meta !== 'undefined' ? (import.meta as { env?: { VITE_SKELETON_SERVICE_PUBLIC_KEY?: string } }).env : undefined;
const configuredKey = env?.VITE_SKELETON_SERVICE_PUBLIC_KEY;
if (configuredKey !== undefined && configuredKey.length === 66 && /^[0-9a-fA-F]{66}$/.test(configuredKey)) {
if (configuredKey.startsWith('02') || configuredKey.startsWith('03')) {
return configuredKey;
@ -117,7 +131,7 @@ function getSkeletonPublicKey(): string {
);
return '02' + '0'.repeat(64);
}
console.warn(
'VITE_SKELETON_SERVICE_PUBLIC_KEY not configured. ' +
'Set it to a valid secp256k1 compressed public key (66 hex chars, starting with 02 or 03). ' +

View File

@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Réseau P2P 4NK un nouveau web</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
line-height: 1.6;
color: #333;
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #222; }
h2 { font-size: 1.2rem; margin-top: 1.5rem; margin-bottom: 0.5rem; color: #333; }
h3 { font-size: 1rem; margin-top: 1rem; margin-bottom: 0.35rem; font-weight: 600; }
p { margin: 0.75rem 0; }
ul { margin: 0.5rem 0; padding-left: 1.5rem; }
li { margin: 0.4rem 0; }
a { color: #007bff; }
a:hover { text-decoration: underline; }
.highlight { background: #e8f4fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #007bff; margin: 1rem 0; }
.info { background: #d4edda; padding: 1rem; border-radius: 8px; border-left: 4px solid #28a745; margin: 1rem 0; }
details { margin: 1rem 0; }
summary { cursor: pointer; font-weight: 600; color: #555; }
.meta { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #666; background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 4px; word-break: break-all; }
.relay-list { list-style: none; padding-left: 0; }
.relay-list > li { background: #f8f9fa; padding: 0.75rem 1rem; border-radius: 8px; border: 1px solid #e0e0e0; margin: 0.5rem 0; }
.relay-list > li > strong { color: #007bff; }
.endpoint-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
.endpoint-table th, .endpoint-table td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }
.endpoint-table th { background: #f5f5f5; font-weight: 600; }
.endpoint-table code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
@media (max-width: 768px) {
body { padding: 0.75rem; }
h1 { font-size: 1.25rem; }
.endpoint-table { font-size: 0.9rem; }
.endpoint-table th, .endpoint-table td { padding: 0.4rem; }
}
</style>
</head>
<body>
<h1>Réseau P2P</h1>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="cryptographie.html">Cryptographie</a></p>
<div class="highlight">
<strong>En résumé :</strong> Au lieu d'utiliser un seul serveur central (comme Gmail ou Facebook),
le système utilise plusieurs <strong>relais</strong> qui fonctionnent comme des boîtes aux lettres publiques.
Vos messages chiffrés sont stockés dans ces relais, et vous pouvez choisir lesquels utiliser.
</div>
<h2>Qu'est-ce qu'un réseau P2P ?</h2>
<p>
Imaginez que vous voulez envoyer une lettre. Dans un système classique (comme la poste traditionnelle),
tous les courriers passent par un seul bureau central. Si ce bureau tombe en panne, plus rien ne fonctionne.
</p>
<p>
Avec un réseau pair-à-pair (P2P), c'est différent : il y a plusieurs <strong>relais</strong> (comme plusieurs
boîtes aux lettres) répartis sur Internet. Vous pouvez choisir où déposer vos messages, et si un relais
ne fonctionne plus, vous pouvez utiliser un autre.
</p>
<p>
Les avantages de cette approche :
</p>
<ul>
<li><strong>Plus de robustesse</strong> : si un relais tombe en panne, d'autres continuent de fonctionner</li>
<li><strong>Pas de point unique de défaillance</strong> : personne ne contrôle tout le système</li>
<li><strong>Vous choisissez</strong> : vous décidez quels relais utiliser, comme choisir votre opérateur téléphonique</li>
<li><strong>Copies multiples</strong> : vos messages peuvent être copiés sur plusieurs relais pour plus de sécurité</li>
</ul>
<h2>Comment fonctionnent les relais ?</h2>
<p>
Un relais, c'est comme une boîte aux lettres publique sur Internet. Quand vous voulez communiquer avec quelqu'un,
vous déposez trois choses séparément dans cette boîte :
</p>
<ol>
<li><strong>Le message chiffré</strong> : votre message codé (comme une lettre dans une enveloppe scellée)</li>
<li><strong>La signature</strong> : une preuve que c'est bien vous qui avez envoyé le message (comme votre signature manuscrite)</li>
<li><strong>La clé de déchiffrement</strong> : la clé pour décoder le message (comme la clé de votre boîte aux lettres)</li>
</ol>
<div class="info">
<strong>Pourquoi séparer ces trois éléments ?</strong> C'est comme mettre votre lettre, votre signature et votre clé
dans trois enveloppes différentes. Même si quelqu'un trouve une enveloppe, il ne peut pas tout faire sans les autres.
C'est plus sûr !
</div>
<h3>Comment récupérer vos messages ?</h3>
<p>
Le système fonctionne comme une boîte aux lettres classique : vous allez vérifier régulièrement s'il y a du courrier.
C'est ce qu'on appelle le modèle <strong>"pull-only"</strong> (vous tirez les informations, elles ne vous sont pas poussées).
</p>
<ul>
<li>Votre appareil vérifie périodiquement les relais pour voir s'il y a de nouveaux messages</li>
<li>Pas de notification instantanée (comme une alerte SMS), mais une vérification régulière</li>
<li>Communication simple via Internet, comme consulter une page web</li>
</ul>
<p>
Cette méthode fonctionne partout, même dans les environnements les plus restrictifs, car elle utilise
simplement le protocole HTTP (comme quand vous visitez un site web).
</p>
<h3>Les relais se parlent entre eux</h3>
<p>
Les relais peuvent être configurés pour se copier mutuellement les messages :
</p>
<ul>
<li>Quand vous déposez un message sur un relais, il peut automatiquement le copier vers d'autres relais</li>
<li>Un système intelligent évite de copier plusieurs fois le même message (grâce à une empreinte unique)</li>
<li>Résultat : vos messages sont disponibles sur plusieurs relais, comme avoir plusieurs copies de sauvegarde</li>
</ul>
<p>
<em>Exemple :</em> Si vous déposez un message sur le relais A, et que A est configuré pour copier vers B et C,
votre message sera disponible sur les trois relais. Si A tombe en panne, vous pouvez toujours récupérer votre message depuis B ou C.
</p>
<h2>Quels relais utiliser ?</h2>
<p>
Par défaut, un relais principal est configuré. Vous pouvez en ajouter d'autres, jusqu'à <strong>8 relais au maximum</strong>.
L'application tente de se connecter à tous les relais activés pour déposer et récupérer les messages.
</p>
<ul class="relay-list">
<li>
<strong>Relais principal (configuré automatiquement)</strong>
<br>Adresse : <span class="meta">https://relay.certificator.4nkweb.com</span>
<br>Ce relais est déjà configuré quand vous utilisez le système pour la première fois.
</li>
</ul>
<div class="info">
<strong>Jusqu'à 8 relais :</strong> Dans les paramètres de UserWallet, vous pouvez ajouter vos propres relais
(comme plusieurs boîtes aux lettres). Maximum 8 au total. Vous activez ou désactivez chaque relais,
et choisissez l'ordre de priorité. L'application utilise tous les relais activés pour déposer et récupérer.
</div>
<h2>Pourquoi dupliquer les relais et les flux ?</h2>
<p>
Plus vous utilisez de relais, plus vos messages ont de chances d'être disponibles et de circuler.
</p>
<ul>
<li><strong>Déposer sur plusieurs relais</strong> : quand vous envoyez un message, l'application le dépose sur chaque relais activé.
Si un relais est indisponible, les autres reçoivent quand même le message. Comme poster la même lettre à plusieurs boîtes.</li>
<li><strong>Récupérer depuis plusieurs relais</strong> : pour lire vos messages, l'application interroge chaque relais.
Si un relais ne répond pas ou n'a pas le message, un autre peut l'avoir. Comme vérifier plusieurs boîtes aux lettres.</li>
<li><strong>Résultat</strong> : plus de robustesse (un relais en panne ne bloque pas tout), plus de chances que vos messages
soient bien livrés, et les relais peuvent se copier entre eux pour multiplier les copies.</li>
</ul>
<p>
En résumé : configurer jusqu'à 8 relais et tous les activer, c'est maximiser les chemins pour envoyer et recevoir,
sans dépendre d'un seul point.
</p>
<h2>Les actions possibles avec un relais</h2>
<p>
Un relais offre plusieurs "actions" (appelées endpoints) que vous pouvez utiliser. C'est comme une boîte aux lettres
avec plusieurs fonctions : déposer, récupérer, vérifier l'état, etc.
</p>
<p>
<em>Note technique :</em> Ces actions utilisent le protocole HTTP (comme les sites web) avec des méthodes GET (lire)
et POST (écrire).
</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Méthode</th>
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>GET</code></td>
<td><code>/health</code></td>
<td>Vérifier si le relais fonctionne correctement</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/messages?start=&lt;ts&gt;&end=&lt;ts&gt;&service=&lt;uuid&gt;</code></td>
<td>Récupérer les messages entre deux dates (optionnel : filtrer par service)</td>
</tr>
<tr>
<td><code>POST</code></td>
<td><code>/messages</code></td>
<td>Déposer un message chiffré dans le relais</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/messages/:hash</code></td>
<td>Récupérer un message précis grâce à son identifiant unique (hash)</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/signatures/:hash</code></td>
<td>Récupérer les signatures associées à un message</td>
</tr>
<tr>
<td><code>POST</code></td>
<td><code>/signatures</code></td>
<td>Déposer une signature dans le relais</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/keys/:hash</code></td>
<td>Récupérer les clés pour déchiffrer un message</td>
</tr>
<tr>
<td><code>POST</code></td>
<td><code>/keys</code></td>
<td>Déposer une clé de déchiffrement dans le relais</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/metrics</code></td>
<td>Consulter les statistiques du relais (nombre de messages, signatures, clés stockées)</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/bloom</code></td>
<td>Obtenir une liste des messages déjà vus (pour éviter de demander plusieurs fois le même message)</td>
</tr>
</tbody>
</table>
<h2>Comment les relais stockent les données</h2>
<h3>Organisation du stockage</h3>
<p>
Les relais organisent les données comme une bibliothèque bien rangée :
</p>
<ul>
<li><strong>Messages</strong> : rangés par identifiant unique (hash) pour les retrouver rapidement</li>
<li><strong>Signatures</strong> : classées par message associé</li>
<li><strong>Clés</strong> : classées par message associé</li>
<li><strong>Éviter les doublons</strong> : le système se souvient des messages déjà vus pour ne pas les stocker plusieurs fois</li>
<li><strong>Indexation</strong> : un système de classement permet de retrouver rapidement les messages d'un service particulier</li>
</ul>
<h3>Protection contre les abus</h3>
<p>
Les relais sont protégés contre les utilisations abusives :
</p>
<ul>
<li><strong>Limitation des requêtes</strong> : chaque adresse IP ne peut faire qu'un nombre limité de requêtes par minute</li>
<li><strong>Contrôle d'accès</strong> : seuls les sites autorisés peuvent utiliser le relais</li>
<li><strong>Timeout</strong> : si une requête prend trop de temps, elle est annulée automatiquement</li>
<li><strong>Compression</strong> : les réponses sont compressées pour être plus rapides</li>
</ul>
<h3>Éviter les doublons</h3>
<p>
Chaque message a une empreinte unique (comme une empreinte digitale). Le système utilise cette empreinte
pour s'assurer qu'un même message n'est pas stocké plusieurs fois, même s'il arrive depuis plusieurs relais.
Cela évite aussi que les messages tournent en boucle entre les relais.
</p>
<details>
<summary>Détails techniques pour les développeurs</summary>
<h3>Configuration des relais pairs</h3>
<p>
Les administrateurs de relais peuvent configurer leurs relais pour se copier mutuellement les messages.
Cela se fait via une variable de configuration :
</p>
<pre class="meta">PEER_RELAYS=http://relay1:3019,http://relay2:3019</pre>
<p>
Quand un message arrive sur un relais, il est automatiquement copié vers tous
les relais pairs configurés (si le message n'a pas déjà été vu).
</p>
<h3>Bloom filter</h3>
<p>
Le endpoint <code>/bloom</code> retourne une liste compacte des messages déjà vus par le relais.
Les applications peuvent utiliser cette liste pour éviter de demander plusieurs fois le même message,
ce qui réduit la charge sur le réseau et améliore les performances.
</p>
<h3>Statistiques du relais</h3>
<p>
Le endpoint <code>/metrics</code> expose des statistiques au format Prometheus (pour le monitoring) :
</p>
<ul>
<li>Nombre de messages stockés</li>
<li>Nombre de signatures stockées</li>
<li>Nombre de clés stockées</li>
</ul>
<h3>Gestion des erreurs et timeouts</h3>
<p>
Les applications utilisent un délai d'attente de 15 secondes pour toutes les requêtes vers les relais.
Si une requête échoue ou prend trop de temps, l'application peut essayer un autre relais configuré,
ou réessayer plus tard avec un délai progressif (backoff exponentiel).
</p>
</details>
<h2>Pourquoi cette architecture ?</h2>
<p>
Cette façon de faire a été choisie pour plusieurs raisons importantes :
</p>
<ul>
<li><strong>Simplicité</strong> : utilise le protocole HTTP standard (comme les sites web), donc ça fonctionne partout</li>
<li><strong>Fiabilité</strong> : pas besoin de maintenir une connexion permanente, comme consulter une page web</li>
<li><strong>Capacité</strong> : chaque relais peut gérer de nombreux utilisateurs en même temps</li>
<li><strong>Contrôle</strong> : vous choisissez quels relais utiliser, personne ne vous impose un choix</li>
<li><strong>Sécurité</strong> : les messages, signatures et clés sont séparés, ce qui rend le système plus sûr</li>
</ul>
<p>
En résumé, c'est un système simple, fiable et qui vous donne le contrôle, tout en étant sécurisé.
</p>
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a> · <a href="cryptographie.html">Cryptographie</a></p>
</body>
</html>

View File

@ -5,7 +5,7 @@ export default defineConfig({
build: {
outDir: 'dist',
rollupOptions: {
input: ['index.html', 'contrat.html', 'membre.html'],
input: ['index.html', 'contrat.html', 'membre.html', 'technique.html', 'cryptographie.html'],
},
},
server: {