Add real service contract for website-skeleton and improve iframe styling
**Motivations:** - website-skeleton needs a real service contract with valid UUIDs and validators - Service wallet required for production use with configurable public key - Iframe styling needs improvement to remove scrollbars and match UserWallet theme **Root causes:** - DEFAULT_VALIDATEURS used placeholder public key that cannot verify signatures - No service wallet generation script for production deployment - Iframe had fixed height causing scrollbars and visual mismatch with dark theme **Correctifs:** - Created real service contract in src/serviceContract.ts with dedicated UUIDs (skeleton-service-uuid-4nkweb-2026) - Added service wallet generation script (generate-service-wallet.mjs) with .env and .env.private files - Improved iframe container styling: increased height (800px), dark background (#1a1a1a), better shadows, hidden scrollbars - Added .env.private to .gitignore for security **Evolutions:** - Service contract automatically loaded on startup and sent to UserWallet iframe - Public key configurable via VITE_SKELETON_SERVICE_PUBLIC_KEY environment variable - Added npm script 'generate-wallet' for easy wallet generation - Enhanced iframe visual integration with UserWallet dark theme **Pages affectées:** - website-skeleton/src/serviceContract.ts (new) - website-skeleton/src/config.ts - website-skeleton/src/main.ts - website-skeleton/generate-service-wallet.mjs (new) - website-skeleton/index.html - website-skeleton/package.json - website-skeleton/.gitignore - website-skeleton/.env (new) - website-skeleton/.env.private (new)
This commit is contained in:
parent
97242d5dab
commit
497bcf0819
@ -1,89 +1,3 @@
|
|||||||
⏳ Traitement: 190000/223585 UTXOs insérés...
|
|
||||||
⏳ Traitement: 200000/223585 UTXOs insérés...
|
|
||||||
⏳ Traitement: 210000/223585 UTXOs insérés...
|
|
||||||
⏳ Traitement: 220000/223585 UTXOs insérés...
|
|
||||||
💾 Mise à jour des UTXOs dépensés...
|
|
||||||
|
|
||||||
📊 Résumé:
|
|
||||||
- UTXOs vérifiés: 48651
|
|
||||||
- UTXOs toujours disponibles: 48651
|
|
||||||
- UTXOs dépensés détectés: 0
|
|
||||||
|
|
||||||
📈 Statistiques finales:
|
|
||||||
- Total UTXOs: 68398
|
|
||||||
- Dépensés: 19747
|
|
||||||
- Non dépensés: 48651
|
|
||||||
|
|
||||||
✅ Synchronisation terminée
|
|
||||||
🔍 Démarrage de la synchronisation des UTXOs dépensés...
|
|
||||||
|
|
||||||
📊 UTXOs à vérifier: 37046
|
|
||||||
📡 Récupération des UTXOs depuis Bitcoin...
|
|
||||||
📊 UTXOs disponibles dans Bitcoin: 221494
|
|
||||||
💾 Création de la table temporaire...
|
|
||||||
💾 Insertion des UTXOs disponibles par batch...
|
|
||||||
⏳ Traitement: 10000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 20000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 30000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 40000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 50000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 60000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 70000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 80000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 90000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 100000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 110000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 120000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 130000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 140000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 150000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 160000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 170000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 180000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 190000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 200000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 210000/221494 UTXOs insérés...
|
|
||||||
⏳ Traitement: 220000/221494 UTXOs insérés...
|
|
||||||
💾 Mise à jour des UTXOs dépensés...
|
|
||||||
|
|
||||||
📊 Résumé:
|
|
||||||
- UTXOs vérifiés: 37046
|
|
||||||
- UTXOs toujours disponibles: 37046
|
|
||||||
- UTXOs dépensés détectés: 0
|
|
||||||
|
|
||||||
📈 Statistiques finales:
|
|
||||||
- Total UTXOs: 68398
|
|
||||||
- Dépensés: 31352
|
|
||||||
- Non dépensés: 37046
|
|
||||||
|
|
||||||
✅ Synchronisation terminée
|
|
||||||
🔍 Démarrage de la synchronisation des UTXOs dépensés...
|
|
||||||
|
|
||||||
📊 UTXOs à vérifier: 5146
|
|
||||||
📡 Récupération des UTXOs depuis Bitcoin...
|
|
||||||
📊 UTXOs disponibles dans Bitcoin: 215703
|
|
||||||
💾 Création de la table temporaire...
|
|
||||||
💾 Insertion des UTXOs disponibles par batch...
|
|
||||||
⏳ Traitement: 10000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 20000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 30000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 40000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 50000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 60000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 70000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 80000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 90000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 100000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 110000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 120000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 130000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 140000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 150000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 160000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 170000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 180000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 190000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 200000/215703 UTXOs insérés...
|
|
||||||
⏳ Traitement: 210000/215703 UTXOs insérés...
|
⏳ Traitement: 210000/215703 UTXOs insérés...
|
||||||
💾 Mise à jour des UTXOs dépensés...
|
💾 Mise à jour des UTXOs dépensés...
|
||||||
|
|
||||||
@ -98,3 +12,89 @@
|
|||||||
- Non dépensés: 5091
|
- Non dépensés: 5091
|
||||||
|
|
||||||
✅ Synchronisation terminée
|
✅ Synchronisation terminée
|
||||||
|
🔍 Démarrage de la synchronisation des UTXOs dépensés...
|
||||||
|
|
||||||
|
📊 UTXOs à vérifier: 213647
|
||||||
|
📡 Récupération des UTXOs depuis Bitcoin...
|
||||||
|
📊 UTXOs disponibles dans Bitcoin: 223744
|
||||||
|
💾 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...
|
||||||
|
💾 Mise à jour des UTXOs dépensés...
|
||||||
|
|
||||||
|
📊 Résumé:
|
||||||
|
- UTXOs vérifiés: 213647
|
||||||
|
- UTXOs toujours disponibles: 213647
|
||||||
|
- UTXOs dépensés détectés: 0
|
||||||
|
|
||||||
|
📈 Statistiques finales:
|
||||||
|
- Total UTXOs: 283165
|
||||||
|
- Dépensés: 69526
|
||||||
|
- Non dépensés: 213639
|
||||||
|
|
||||||
|
✅ Synchronisation terminée
|
||||||
|
🔍 Démarrage de la synchronisation des UTXOs dépensés...
|
||||||
|
|
||||||
|
📊 UTXOs à vérifier: 211454
|
||||||
|
📡 Récupération des UTXOs depuis Bitcoin...
|
||||||
|
📊 UTXOs disponibles dans Bitcoin: 241303
|
||||||
|
💾 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...
|
||||||
|
💾 Mise à jour des UTXOs dépensés...
|
||||||
|
|
||||||
|
📊 Résumé:
|
||||||
|
- UTXOs vérifiés: 211454
|
||||||
|
- UTXOs toujours disponibles: 211454
|
||||||
|
- UTXOs dépensés détectés: 0
|
||||||
|
|
||||||
|
📈 Statistiques finales:
|
||||||
|
- Total UTXOs: 283165
|
||||||
|
- Dépensés: 71719
|
||||||
|
- Non dépensés: 211446
|
||||||
|
|
||||||
|
✅ Synchronisation terminée
|
||||||
|
|||||||
202
docs/PAIRING_PUBLIC_KEY_ENCODING.md
Normal file
202
docs/PAIRING_PUBLIC_KEY_ENCODING.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Pairing : Encodage de la clé publique dans les mots BIP32
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-28
|
||||||
|
**Version:** 1.0
|
||||||
|
|
||||||
|
## Problème identifié
|
||||||
|
|
||||||
|
### Question initiale
|
||||||
|
|
||||||
|
Si les mots BIP32 encodent l'UUID du pair, cela implique que :
|
||||||
|
- Le pair a déjà été créé et publié sur un relais
|
||||||
|
- Il faut récupérer le pair depuis le relais pour obtenir sa clé publique
|
||||||
|
- **Mais lors du pairing initial, le pair n'existe pas encore sur le relais**
|
||||||
|
|
||||||
|
### Problème technique
|
||||||
|
|
||||||
|
Il n'existe **pas de mécanisme** pour récupérer un pair par UUID sur un relais. Les relais permettent uniquement de :
|
||||||
|
- Récupérer des messages par hash : `GET /messages/:hash`
|
||||||
|
- Récupérer des messages par fenêtre temporelle : `GET /messages?start=&end=`
|
||||||
|
|
||||||
|
Il n'y a pas d'endpoint `GET /pairs/:uuid` ou équivalent.
|
||||||
|
|
||||||
|
### Conséquence
|
||||||
|
|
||||||
|
Si les mots encodent l'UUID du pair :
|
||||||
|
1. Le pair doit être publié sur un relais avant le pairing
|
||||||
|
2. Il faut un mécanisme pour le récupérer par UUID (qui n'existe pas)
|
||||||
|
3. Le pairing initial devient impossible sans publication préalable
|
||||||
|
|
||||||
|
## Solution implémentée
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
Les mots BIP32 encodent maintenant **directement la clé publique de l'identité** (66 hex = 33 bytes) au lieu de l'UUID du pair.
|
||||||
|
|
||||||
|
### Avantages
|
||||||
|
|
||||||
|
- ✅ **Pas besoin de récupérer quoi que ce soit depuis un relais**
|
||||||
|
- ✅ **La clé publique est disponible immédiatement** (elle fait partie de l'identité)
|
||||||
|
- ✅ **Les mots peuvent être échangés directement** entre les deux devices
|
||||||
|
- ✅ **La clé publique est stockée dans `PairConfig.publicKey`** et utilisée pour ECDH
|
||||||
|
- ✅ **Le pairing initial fonctionne sans publication préalable**
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
- **Clé publique** : 66 caractères hex (33 bytes)
|
||||||
|
- **Encodage** : 17 mots BIP32
|
||||||
|
- 16 mots pour les 32 premiers bytes (2 bytes par mot)
|
||||||
|
- 1 mot pour le dernier byte (33ème byte, padding avec 0)
|
||||||
|
|
||||||
|
## Modifications techniques
|
||||||
|
|
||||||
|
### Nouvelles fonctions (`userwallet/src/utils/bip32.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Convert public key (hex, 66 chars) to BIP32 word list.
|
||||||
|
* Public key is 33 bytes. Each word encodes 2 bytes (16 bits), so we need 17 words.
|
||||||
|
* Last word encodes only 1 byte (the 33rd byte), so we pad with 0.
|
||||||
|
*/
|
||||||
|
export function publicKeyToBip32Words(publicKey: string): string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert BIP32 word list back to public key (hex, 66 chars).
|
||||||
|
* Expects 17 words: 16 words for 32 bytes + 1 word for the last byte.
|
||||||
|
*/
|
||||||
|
export function bip32WordsToPublicKey(words: string[]): string | null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifications du pairing (`userwallet/src/utils/pairing.ts`)
|
||||||
|
|
||||||
|
#### `createLocalPair()`
|
||||||
|
|
||||||
|
**Avant :**
|
||||||
|
- Génère un UUID
|
||||||
|
- Encode l'UUID en mots BIP32 (8 mots)
|
||||||
|
|
||||||
|
**Maintenant :**
|
||||||
|
- Génère un UUID pour le pair
|
||||||
|
- Encode la clé publique de l'identité en mots BIP32 (17 mots)
|
||||||
|
- Stocke la clé publique dans `PairConfig.publicKey`
|
||||||
|
|
||||||
|
#### `addRemotePairFromWords()`
|
||||||
|
|
||||||
|
**Avant :**
|
||||||
|
- Décode les mots pour obtenir l'UUID du pair
|
||||||
|
- Clé publique optionnelle (fournie séparément)
|
||||||
|
|
||||||
|
**Maintenant :**
|
||||||
|
- Décode les mots pour obtenir directement la clé publique
|
||||||
|
- Génère un UUID pour le pair localement
|
||||||
|
- Stocke automatiquement la clé publique dans `PairConfig.publicKey`
|
||||||
|
|
||||||
|
#### `ensureLocalPairForSetup()`
|
||||||
|
|
||||||
|
**Avant :**
|
||||||
|
- Retourne les mots de l'UUID du pair local
|
||||||
|
|
||||||
|
**Maintenant :**
|
||||||
|
- Nécessite la clé publique de l'identité en paramètre
|
||||||
|
- Retourne les mots de la clé publique
|
||||||
|
|
||||||
|
#### `parseAndValidatePairingWords()`
|
||||||
|
|
||||||
|
**Avant :**
|
||||||
|
- Valide 8 mots
|
||||||
|
- Vérifie qu'ils décodent un UUID valide
|
||||||
|
|
||||||
|
**Maintenant :**
|
||||||
|
- Valide 17 mots
|
||||||
|
- Vérifie qu'ils décodent une clé publique valide (préfixe 02/03/04)
|
||||||
|
|
||||||
|
### Modifications de l'interface utilisateur
|
||||||
|
|
||||||
|
#### Messages d'erreur
|
||||||
|
|
||||||
|
- **Avant** : "Mots invalides. 8 mots requis."
|
||||||
|
- **Maintenant** : "Mots invalides. 17 mots requis."
|
||||||
|
|
||||||
|
#### Composants affectés
|
||||||
|
|
||||||
|
- `PairingDisplayScreen.tsx` : Saisie des mots du 1er appareil
|
||||||
|
- `PairingSetupBlock.tsx` : Saisie des mots du 2ème appareil
|
||||||
|
- `PairManagementScreen.tsx` : Affichage des mots de la clé publique
|
||||||
|
|
||||||
|
### ECDH et pairing
|
||||||
|
|
||||||
|
La clé publique extraite des mots est :
|
||||||
|
1. **Stockée automatiquement** dans `PairConfig.publicKey`
|
||||||
|
2. **Utilisée pour ECDH** lors du pairing (`publishPairingMessage`)
|
||||||
|
3. **Récupérée depuis les signatures** si nécessaire (fallback)
|
||||||
|
|
||||||
|
## Flux de pairing
|
||||||
|
|
||||||
|
### Device 1 (premier appareil)
|
||||||
|
|
||||||
|
1. Génère un UUID pour le pair local
|
||||||
|
2. Encode la clé publique de son identité en 17 mots BIP32
|
||||||
|
3. Affiche les mots pour le device 2
|
||||||
|
4. Reçoit les mots du device 2
|
||||||
|
5. Décode les mots pour obtenir la clé publique du device 2
|
||||||
|
6. Crée le pair distant avec cette clé publique
|
||||||
|
7. Utilise la clé publique pour ECDH lors du pairing
|
||||||
|
|
||||||
|
### Device 2 (second appareil)
|
||||||
|
|
||||||
|
1. Génère un UUID pour le pair local
|
||||||
|
2. Encode la clé publique de son identité en 17 mots BIP32
|
||||||
|
3. Affiche les mots pour le device 1
|
||||||
|
4. Reçoit les mots du device 1
|
||||||
|
5. Décode les mots pour obtenir la clé publique du device 1
|
||||||
|
6. Crée le pair distant avec cette clé publique
|
||||||
|
7. Utilise la clé publique pour ECDH lors du pairing
|
||||||
|
|
||||||
|
## Comparaison avant/après
|
||||||
|
|
||||||
|
### Avant (UUID)
|
||||||
|
|
||||||
|
```
|
||||||
|
Mots → UUID du pair → Besoin de récupérer le pair depuis un relais
|
||||||
|
→ Problème : pas d'endpoint pour récupérer par UUID
|
||||||
|
→ Problème : le pair n'existe pas encore sur le relais
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maintenant (Clé publique)
|
||||||
|
|
||||||
|
```
|
||||||
|
Mots → Clé publique directement → Stockée dans PairConfig.publicKey
|
||||||
|
→ Utilisée pour ECDH
|
||||||
|
→ Pas besoin de relais
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### Positif
|
||||||
|
|
||||||
|
- ✅ Pairing initial fonctionne sans publication préalable
|
||||||
|
- ✅ Pas de dépendance à un relais pour le pairing
|
||||||
|
- ✅ Clé publique disponible immédiatement
|
||||||
|
- ✅ ECDH fonctionne dès le pairing
|
||||||
|
|
||||||
|
### Changements nécessaires
|
||||||
|
|
||||||
|
- ⚠️ Les mots passent de 8 à 17 mots
|
||||||
|
- ⚠️ Les anciens pairs avec mots UUID ne sont plus compatibles
|
||||||
|
- ⚠️ Migration nécessaire si des pairs existent déjà
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Si des pairs existent déjà avec l'ancien format (UUID) :
|
||||||
|
1. Les pairs locaux peuvent être régénérés avec la nouvelle méthode
|
||||||
|
2. Les pairs distants doivent être re-pairés avec les nouveaux mots (17 mots)
|
||||||
|
3. L'ancien format (8 mots) n'est plus supporté
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- `userwallet/src/utils/bip32.ts` : Fonctions d'encodage/décodage
|
||||||
|
- `userwallet/src/utils/pairing.ts` : Logique de pairing
|
||||||
|
- `userwallet/src/services/pairingConfirm.ts` : Confirmation de pairing avec ECDH
|
||||||
|
- `userwallet/src/components/PairingDisplayScreen.tsx` : Interface de pairing
|
||||||
|
- `userwallet/src/components/PairingSetupBlock.tsx` : Configuration de pairing
|
||||||
@ -65,6 +65,12 @@ Ce dossier contient toute la documentation nécessaire pour la maintenance et l'
|
|||||||
- Configuration (origine UserWallet, validateurs)
|
- Configuration (origine UserWallet, validateurs)
|
||||||
- Utilisation, messages postMessage, références
|
- Utilisation, messages postMessage, références
|
||||||
|
|
||||||
|
- **[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)
|
||||||
|
- Modifications techniques et impact
|
||||||
|
- Flux de pairing et migration
|
||||||
|
|
||||||
## Démarrage Rapide
|
## Démarrage Rapide
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# website-skeleton
|
# website-skeleton
|
||||||
|
|
||||||
Squelette d’un site web qui intègre UserWallet en iframe : écoute des messages `postMessage` (auth-request, login-proof, error), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé).
|
Squelette d’un site web qui intègre UserWallet en iframe : écoute des messages `postMessage` (login-proof, error, contract), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé). Connexion via un seul parcours : « Se connecter » puis authentification MFA dans l'iframe.
|
||||||
|
|
||||||
## Objectif
|
## Objectif
|
||||||
|
|
||||||
@ -21,8 +21,9 @@ Utile comme base pour un service (certificator, zapwall, etc.) qui consomme User
|
|||||||
|
|
||||||
| Fichier | Rôle |
|
| Fichier | Rôle |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| `index.html` | Page avec iframe UserWallet, zone de statut, bouton « Demander auth » |
|
| `index.html` | Page avec iframe UserWallet, zone de statut, lien « Description du contrat », bouton « Se connecter » |
|
||||||
| `src/main.ts` | Chargement iframe, écoute `message`, envoi `auth-request`, appel `verifyLoginProof`, mise à jour du statut |
|
| `contrat.html` | Page de description du contrat de service skeleton (labels, UUIDs, usage) |
|
||||||
|
| `src/main.ts` | Chargement iframe, écoute `message`, envoi du contrat au clic « Se connecter », appel `verifyLoginProof`, mise à jour du statut |
|
||||||
| `src/config.ts` | `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS` (placeholder) |
|
| `src/config.ts` | `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS` (placeholder) |
|
||||||
| `package.json` | Scripts `dev` / `build` / `preview`, dépendance `service-login-verify` |
|
| `package.json` | Scripts `dev` / `build` / `preview`, dépendance `service-login-verify` |
|
||||||
| `start.sh` | Build + `vite preview` sur le port 3024 (production) |
|
| `start.sh` | Build + `vite preview` sur le port 3024 (production) |
|
||||||
@ -69,15 +70,15 @@ npm run build
|
|||||||
1. Lancer UserWallet (dev ou déployé) sur l’URL configurée.
|
1. Lancer UserWallet (dev ou déployé) sur l’URL configurée.
|
||||||
2. Lancer le skeleton (`npm run dev` ou servir `dist/`).
|
2. Lancer le skeleton (`npm run dev` ou servir `dist/`).
|
||||||
3. Ouvrir la page du skeleton : l’iframe affiche UserWallet.
|
3. Ouvrir la page du skeleton : l’iframe affiche UserWallet.
|
||||||
4. **Demander auth** : cliquer « Demander auth (auth-request) » → envoi de `auth-request` à l’iframe.
|
4. **Se connecter** : cliquer « Se connecter » → envoi du contrat à l’iframe, affichage de l’iframe.
|
||||||
5. **Login** : effectuer le flux de login dans l’iframe ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton appelle `verifyLoginProof`, puis affiche « Login accepté » ou « Login refusé : … ».
|
5. **Login MFA** : effectuer le flux de login (MFA) dans l’iframe ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton appelle `verifyLoginProof`, puis affiche « Login accepté » ou « Login refusé : … ».
|
||||||
|
6. **Description du contrat** : lien « Description du contrat » vers `contrat.html`.
|
||||||
|
|
||||||
## Messages postMessage
|
## Messages postMessage
|
||||||
|
|
||||||
| Type | Sens | Rôle du skeleton |
|
| Type | Sens | Rôle du skeleton |
|
||||||
|------|------|-------------------|
|
|------|------|-------------------|
|
||||||
| `auth-request` | Parent → iframe | Envoyé au clic sur « Demander auth ». |
|
| `contract` | Parent → iframe | Envoyé au clic sur « Se connecter » (et au load de l’iframe). |
|
||||||
| `auth-response` | Iframe → parent | Affichage « Auth reçu ». |
|
|
||||||
| `login-proof` | Iframe → parent | Vérification via `verifyLoginProof`, mise à jour du statut. |
|
| `login-proof` | Iframe → parent | Vérification via `verifyLoginProof`, mise à jour du statut. |
|
||||||
| `error` | Iframe → parent | Affichage du message d’erreur. |
|
| `error` | Iframe → parent | Affichage du message d’erreur. |
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { NonceCacheLike } from './types.js';
|
import type { NonceCacheLike } from './types.js';
|
||||||
/**
|
/**
|
||||||
* Persistent nonce cache using IndexedDB (browser) or localStorage (fallback).
|
* Persistent nonce cache using IndexedDB (browser) and localStorage.
|
||||||
* Implements NonceCacheLike interface for use with verifyLoginProof.
|
* Implements NonceCacheLike interface for use with verifyLoginProof.
|
||||||
*/
|
*/
|
||||||
export declare class PersistentNonceCache implements NonceCacheLike {
|
export declare class PersistentNonceCache implements NonceCacheLike {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Persistent nonce cache using IndexedDB (browser) or localStorage (fallback).
|
* Persistent nonce cache using IndexedDB (browser) and localStorage.
|
||||||
* Implements NonceCacheLike interface for use with verifyLoginProof.
|
* Implements NonceCacheLike interface for use with verifyLoginProof.
|
||||||
*/
|
*/
|
||||||
export class PersistentNonceCache {
|
export class PersistentNonceCache {
|
||||||
|
|||||||
@ -116,7 +116,7 @@ export function GlobalActionBar(): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowMots(true)}
|
onClick={() => setShowMots(true)}
|
||||||
title="Afficher les 8 mots (représentation clé publique, format type BIP32)"
|
title="Afficher les 17 mots (représentation clé publique, format type BIP32)"
|
||||||
aria-label="Afficher les mots de ma clé publique"
|
aria-label="Afficher les mots de ma clé publique"
|
||||||
>
|
>
|
||||||
Afficher les mots de ma clé publique
|
Afficher les mots de ma clé publique
|
||||||
|
|||||||
@ -1,99 +1,187 @@
|
|||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getStoredPairs } from '../utils/pairing';
|
import { Link } from 'react-router-dom';
|
||||||
import { getStoredRelays } from '../utils/relay';
|
import { getStoredPairs, removePair } from '../utils/pairing';
|
||||||
|
import { publicKeyToBip32Words } from '../utils/bip32';
|
||||||
import { useIdentity } from '../hooks/useIdentity';
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
import { GraphResolver } from '../services/graphResolver';
|
import type { PairConfig } from '../types/identity';
|
||||||
import { SyncService } from '../services/syncService';
|
|
||||||
import type { LocalIdentity } from '../types/identity';
|
|
||||||
|
|
||||||
function logPairsConfig(): void {
|
|
||||||
const pairs = getStoredPairs();
|
|
||||||
const lines = ['[UserWallet] Pairs configurés (log):'];
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
lines.push('Aucun pair configuré');
|
|
||||||
} else {
|
|
||||||
for (const p of pairs) {
|
|
||||||
lines.push(
|
|
||||||
`UUID: ${p.uuid.slice(0, 16)}...`,
|
|
||||||
`Local: ${p.is_local ? 'Oui' : 'Non'}`,
|
|
||||||
`Peut signer: ${p.can_sign ? 'Oui' : 'Non'}`,
|
|
||||||
);
|
|
||||||
lines.push('---');
|
|
||||||
}
|
|
||||||
lines.pop();
|
|
||||||
}
|
|
||||||
console.warn(lines.join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logContrats(identity: LocalIdentity): Promise<void> {
|
|
||||||
const relays = getStoredRelays().filter((r) => r.enabled);
|
|
||||||
if (relays.length === 0) {
|
|
||||||
console.warn('[UserWallet] Log contrats: aucun relais activé.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const graphResolver = new GraphResolver();
|
|
||||||
const syncService = new SyncService(relays, graphResolver, identity);
|
|
||||||
await syncService.init();
|
|
||||||
await syncService.sync(identity.t0_anniversaire, Date.now());
|
|
||||||
|
|
||||||
const pairs = graphResolver.getPairs();
|
|
||||||
console.warn('[UserWallet] Log contrat du pair:', JSON.stringify(pairs, null, 2));
|
|
||||||
|
|
||||||
const membres = graphResolver.getMembres();
|
|
||||||
console.warn('[UserWallet] Log contrat du membre:', JSON.stringify(membres, null, 2));
|
|
||||||
|
|
||||||
const services = graphResolver.getServices();
|
|
||||||
const defaultService = services[0];
|
|
||||||
if (defaultService === undefined) {
|
|
||||||
console.warn('[UserWallet] Log contrat du service par défaut: aucun service.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contrats = graphResolver.getContrats();
|
|
||||||
const contratService = contrats.find((c) => c.uuid === defaultService.contrat_uuid);
|
|
||||||
console.warn(
|
|
||||||
'[UserWallet] Log contrat du service par défaut:',
|
|
||||||
JSON.stringify(contratService ?? null, null, 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const membreDef = membres.find((m) =>
|
|
||||||
m.datajson.services_uuid.includes(defaultService.uuid),
|
|
||||||
);
|
|
||||||
if (membreDef === undefined) {
|
|
||||||
console.warn(
|
|
||||||
'[UserWallet] Log action de login (service par défaut): aucun membre.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = graphResolver.resolveLoginPath(defaultService.uuid, membreDef.uuid);
|
|
||||||
if (path === null || path.action_login_uuid === '') {
|
|
||||||
console.warn(
|
|
||||||
'[UserWallet] Log action de login (service par défaut): chemin incomplet.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = graphResolver.getActions();
|
|
||||||
const actionLogin = actions.find((a) => a.uuid === path.action_login_uuid);
|
|
||||||
console.warn(
|
|
||||||
'[UserWallet] Log action de login du contrat du service par défaut:',
|
|
||||||
JSON.stringify(actionLogin ?? null, null, 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PairManagementScreen(): JSX.Element {
|
export function PairManagementScreen(): JSX.Element {
|
||||||
const { identity } = useIdentity();
|
const { identity } = useIdentity();
|
||||||
|
const [pairs, setPairs] = useState<PairConfig[]>([]);
|
||||||
|
const [showWords, setShowWords] = useState<Set<string>>(new Set());
|
||||||
|
const [visibleWordIndices, setVisibleWordIndices] = useState<Map<string, Set<number>>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logPairsConfig();
|
const stored = getStoredPairs();
|
||||||
if (identity !== null) {
|
setPairs(stored);
|
||||||
void logContrats(identity).catch((err: unknown) => {
|
}, []);
|
||||||
console.error('[UserWallet] Log contrats failed:', err);
|
|
||||||
|
const handleRemove = (pairUuid: string): void => {
|
||||||
|
if (confirm('Voulez-vous vraiment supprimer ce pair ?')) {
|
||||||
|
removePair(pairUuid);
|
||||||
|
setPairs(getStoredPairs());
|
||||||
|
setShowWords((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(pairUuid);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [identity]);
|
};
|
||||||
|
|
||||||
return <></>;
|
const handleToggleWords = (pairUuid: string): void => {
|
||||||
|
setShowWords((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(pairUuid)) {
|
||||||
|
next.delete(pairUuid);
|
||||||
|
// Reset visible words when hiding
|
||||||
|
setVisibleWordIndices((prevVisible) => {
|
||||||
|
const nextVisible = new Map(prevVisible);
|
||||||
|
nextVisible.delete(pairUuid);
|
||||||
|
return nextVisible;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next.add(pairUuid);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleWordVisibility = (pairUuid: string, wordIndex: number): void => {
|
||||||
|
setVisibleWordIndices((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const pairVisible = next.get(pairUuid) ?? new Set<number>();
|
||||||
|
const newPairVisible = new Set(pairVisible);
|
||||||
|
if (newPairVisible.has(wordIndex)) {
|
||||||
|
newPairVisible.delete(wordIndex);
|
||||||
|
} else {
|
||||||
|
newPairVisible.add(wordIndex);
|
||||||
|
}
|
||||||
|
next.set(pairUuid, newPairVisible);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWordsForPair = (pair: PairConfig): string[] | null => {
|
||||||
|
if (pair.publicKey !== undefined) {
|
||||||
|
return publicKeyToBip32Words(pair.publicKey);
|
||||||
|
}
|
||||||
|
if (pair.is_local && identity !== null && identity.publicKey !== undefined) {
|
||||||
|
return publicKeyToBip32Words(identity.publicKey);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublicKeyForPair = (pair: PairConfig): string | null => {
|
||||||
|
if (pair.is_local && identity !== null) {
|
||||||
|
return identity.publicKey;
|
||||||
|
}
|
||||||
|
return pair.publicKey ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Gérer les pairs</h1>
|
||||||
|
<p>
|
||||||
|
<Link to="/">← Accueil</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{pairs.length === 0 ? (
|
||||||
|
<p>Aucun pair configuré.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{pairs.map((pair) => {
|
||||||
|
const words = getWordsForPair(pair);
|
||||||
|
const publicKey = getPublicKeyForPair(pair);
|
||||||
|
const isShowingWords = showWords.has(pair.uuid);
|
||||||
|
|
||||||
|
if (words === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={pair.uuid}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
{pair.is_local ? 'Pair local' : 'Pair distant'}
|
||||||
|
{pair.can_sign ? ' (peut signer)' : ' (ne peut pas signer)'}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
<strong>UUID:</strong>{' '}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||||
|
{pair.uuid}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{publicKey !== null && (
|
||||||
|
<p>
|
||||||
|
<strong>Clé publique:</strong>{' '}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.85rem', wordBreak: 'break-all' }}>
|
||||||
|
{publicKey}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isShowingWords && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong>Mots (8):</strong>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '1rem',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{words.join(' ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleWords(pair.uuid)}
|
||||||
|
style={{ padding: '0.5rem 1rem' }}
|
||||||
|
>
|
||||||
|
{isShowingWords ? 'Masquer les mots' : 'Afficher les mots'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(pair.uuid)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
backgroundColor: '#dc3545',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,16 +19,17 @@ export function PairingDisplayScreen(): JSX.Element {
|
|||||||
const { connected: pairingConnected } = usePairingConnected();
|
const { connected: pairingConnected } = usePairingConnected();
|
||||||
const [words2nd, setWords2nd] = useState<string[]>([]);
|
const [words2nd, setWords2nd] = useState<string[]>([]);
|
||||||
const [wordInput, setWordInput] = useState<string[]>([]);
|
const [wordInput, setWordInput] = useState<string[]>([]);
|
||||||
const [pubkey1stInput, setPubkey1stInput] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
const [justConnected, setJustConnected] = useState(false);
|
const [justConnected, setJustConnected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const w = ensureLocalPairForSetup();
|
if (identity !== null && identity.publicKey !== undefined) {
|
||||||
setWords2nd(w);
|
const w = ensureLocalPairForSetup(identity.publicKey);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
setWords2nd(w);
|
||||||
|
}
|
||||||
|
}, [identity]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || identity !== null) {
|
if (isLoading || identity !== null) {
|
||||||
@ -51,21 +52,10 @@ export function PairingDisplayScreen(): JSX.Element {
|
|||||||
const wordsText = wordInput.join(' ');
|
const wordsText = wordInput.join(' ');
|
||||||
const parsed = parseAndValidatePairingWords(wordsText);
|
const parsed = parseAndValidatePairingWords(wordsText);
|
||||||
if (parsed === null) {
|
if (parsed === null) {
|
||||||
setError('Mots invalides. 8 mots requis.');
|
setError('Mots invalides. 17 mots requis.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pubkeyHex = pubkey1stInput.trim();
|
const pair = addRemotePairFromWords(parsed, []);
|
||||||
if (pubkeyHex.length === 0) {
|
|
||||||
setError(
|
|
||||||
'Pairing DH obligatoire : clé publique du 1ᵉʳ appareil requise (hex, 66 car.).',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pubkeyHex.length !== 66 || !['02', '03', '04'].includes(pubkeyHex.slice(0, 2))) {
|
|
||||||
setError('Clé publique invalide (hex 66 car., préfixe 02/03/04).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pair = addRemotePairFromWords(parsed, [], pubkeyHex);
|
|
||||||
if (pair === null) {
|
if (pair === null) {
|
||||||
setError('Mots invalides. Vérifiez la saisie.');
|
setError('Mots invalides. Vérifiez la saisie.');
|
||||||
return;
|
return;
|
||||||
@ -87,6 +77,8 @@ export function PairingDisplayScreen(): JSX.Element {
|
|||||||
setError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
|
setError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Use pair's publicKey if available (will be updated from signatures if not)
|
||||||
|
const remotePublicKey = remote.publicKey;
|
||||||
setIsConfirming(true);
|
setIsConfirming(true);
|
||||||
try {
|
try {
|
||||||
const ok = await runDevice2Confirmation({
|
const ok = await runDevice2Confirmation({
|
||||||
@ -96,7 +88,7 @@ export function PairingDisplayScreen(): JSX.Element {
|
|||||||
relays,
|
relays,
|
||||||
start: identity.t0_anniversaire,
|
start: identity.t0_anniversaire,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
remotePublicKey: pubkeyHex,
|
remotePublicKey,
|
||||||
});
|
});
|
||||||
setJustConnected(ok);
|
setJustConnected(ok);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -193,32 +185,22 @@ export function PairingDisplayScreen(): JSX.Element {
|
|||||||
<main>
|
<main>
|
||||||
<h1>Saisir les mots du 1ᵉʳ appareil</h1>
|
<h1>Saisir les mots du 1ᵉʳ appareil</h1>
|
||||||
<p>
|
<p>
|
||||||
Saisissez les 8 mots et la clé publique affichés par le 1ᵉʳ appareil.
|
Saisissez les 17 mots affichés par le 1ᵉʳ appareil.
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={(ev) => void handleSubmit(ev)}
|
onSubmit={(ev) => void handleSubmit(ev)}
|
||||||
aria-label="Saisir les mots et clé publique du 1er appareil"
|
aria-label="Saisir les mots du 1er appareil"
|
||||||
>
|
>
|
||||||
<label htmlFor="pairing-words-display">
|
<label htmlFor="pairing-words-display">
|
||||||
Mots du 1ᵉʳ appareil
|
Mots du 1ᵉʳ appareil
|
||||||
<WordInputGrid
|
<WordInputGrid
|
||||||
id="pairing-words-display"
|
id="pairing-words-display"
|
||||||
value={wordInput}
|
value={wordInput}
|
||||||
onChange={setWordInput}
|
onChange={setWordInput}
|
||||||
aria-describedby={error !== null ? 'pairing-display-err' : undefined}
|
aria-describedby={error !== null ? 'pairing-display-err' : undefined}
|
||||||
aria-label="Saisir les 8 mots du 1er appareil"
|
aria-label="Saisir les 17 mots du 1er appareil"
|
||||||
/>
|
wordCount={17}
|
||||||
</label>
|
/>
|
||||||
<label htmlFor="pubkey-1st">
|
|
||||||
Clé publique du 1ᵉʳ appareil (hex, 66 car.)
|
|
||||||
<input
|
|
||||||
id="pubkey-1st"
|
|
||||||
type="text"
|
|
||||||
value={pubkey1stInput}
|
|
||||||
onChange={(e) => setPubkey1stInput(e.target.value)}
|
|
||||||
placeholder="02… ou 03… ou 04…"
|
|
||||||
aria-describedby={error !== null ? 'pairing-display-err' : undefined}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
{error !== null && (
|
{error !== null && (
|
||||||
<p id="pairing-display-err" role="alert" style={{ color: 'var(--color-error)' }}>
|
<p id="pairing-display-err" role="alert" style={{ color: 'var(--color-error)' }}>
|
||||||
|
|||||||
@ -25,15 +25,16 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
const [words, setWords] = useState<string[]>([]);
|
const [words, setWords] = useState<string[]>([]);
|
||||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
const [remoteWordsInput, setRemoteWordsInput] = useState<string[]>([]);
|
const [remoteWordsInput, setRemoteWordsInput] = useState<string[]>([]);
|
||||||
const [remotePubkeyInput, setRemotePubkeyInput] = useState('');
|
|
||||||
const [remoteError, setRemoteError] = useState<string | null>(null);
|
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||||
const [hasCopiedToSecondDevice, setHasCopiedToSecondDevice] = useState(false);
|
const [hasCopiedToSecondDevice, setHasCopiedToSecondDevice] = useState(false);
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const w = ensureLocalPairForSetup();
|
if (identity !== null && identity.publicKey !== undefined) {
|
||||||
setWords(w);
|
const w = ensureLocalPairForSetup(identity.publicKey);
|
||||||
}, []);
|
setWords(w);
|
||||||
|
}
|
||||||
|
}, [identity]);
|
||||||
|
|
||||||
const url = useMemo(() => buildPairingDisplayUrl(), []);
|
const url = useMemo(() => buildPairingDisplayUrl(), []);
|
||||||
|
|
||||||
@ -54,21 +55,10 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
const wordsText = remoteWordsInput.join(' ');
|
const wordsText = remoteWordsInput.join(' ');
|
||||||
const parsed = parseAndValidatePairingWords(wordsText);
|
const parsed = parseAndValidatePairingWords(wordsText);
|
||||||
if (parsed === null) {
|
if (parsed === null) {
|
||||||
setRemoteError('Mots invalides. 8 mots requis.');
|
setRemoteError('Mots invalides. 17 mots requis.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pubkeyHex = remotePubkeyInput.trim();
|
const pair = addRemotePairFromWords(parsed, []);
|
||||||
if (pubkeyHex.length === 0) {
|
|
||||||
setRemoteError(
|
|
||||||
'Pairing DH obligatoire : clé publique du 2ᵉ appareil requise (hex, 66 car.).',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pubkeyHex.length !== 66 || !['02', '03', '04'].includes(pubkeyHex.slice(0, 2))) {
|
|
||||||
setRemoteError('Clé publique invalide (hex 66 car., préfixe 02/03/04).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pair = addRemotePairFromWords(parsed, [], pubkeyHex);
|
|
||||||
if (pair === null) {
|
if (pair === null) {
|
||||||
setRemoteError('Mots invalides. Vérifiez la saisie.');
|
setRemoteError('Mots invalides. Vérifiez la saisie.');
|
||||||
return;
|
return;
|
||||||
@ -83,7 +73,6 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
identity.privateKey === undefined
|
identity.privateKey === undefined
|
||||||
) {
|
) {
|
||||||
setRemoteWordsInput([]);
|
setRemoteWordsInput([]);
|
||||||
setRemotePubkeyInput('');
|
|
||||||
navigate('/manage-pairs');
|
navigate('/manage-pairs');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -92,6 +81,8 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
setRemoteError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
|
setRemoteError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Use pair's publicKey if available (will be updated from signatures if not)
|
||||||
|
const remotePublicKey = remote.publicKey;
|
||||||
setIsConfirming(true);
|
setIsConfirming(true);
|
||||||
try {
|
try {
|
||||||
await runDevice1Confirmation({
|
await runDevice1Confirmation({
|
||||||
@ -99,7 +90,7 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
pairRemote: remote.uuid,
|
pairRemote: remote.uuid,
|
||||||
identity,
|
identity,
|
||||||
relays,
|
relays,
|
||||||
remotePublicKey: pubkeyHex,
|
remotePublicKey,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Pairing confirmation (device 1):', err);
|
console.error('Pairing confirmation (device 1):', err);
|
||||||
@ -111,7 +102,6 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
}
|
}
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
setRemoteWordsInput([]);
|
setRemoteWordsInput([]);
|
||||||
setRemotePubkeyInput('');
|
|
||||||
navigate('/manage-pairs');
|
navigate('/manage-pairs');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,18 +121,6 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
>
|
>
|
||||||
{words.join(' ')}
|
{words.join(' ')}
|
||||||
</p>
|
</p>
|
||||||
{identity !== null && (
|
|
||||||
<p>
|
|
||||||
<strong>Clé publique de ce dispositif</strong> — à saisir sur le 2ᵉ (hex) :
|
|
||||||
<br />
|
|
||||||
<span
|
|
||||||
aria-label="Clé publique 1er appareil"
|
|
||||||
style={{ fontFamily: 'monospace', fontSize: '0.9rem', wordBreak: 'break-all' }}
|
|
||||||
>
|
|
||||||
{identity.publicKey}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{qrDataUrl !== null && (
|
{qrDataUrl !== null && (
|
||||||
<p>
|
<p>
|
||||||
<img
|
<img
|
||||||
@ -173,7 +151,7 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
<h4 id="remote-words-heading">Mots du 2ᵉ appareil</h4>
|
<h4 id="remote-words-heading">Mots du 2ᵉ appareil</h4>
|
||||||
<form
|
<form
|
||||||
onSubmit={(ev) => void handleSubmitRemote(ev)}
|
onSubmit={(ev) => void handleSubmitRemote(ev)}
|
||||||
aria-label="Saisir les mots et clé publique du 2e appareil"
|
aria-label="Saisir les mots du 2e appareil"
|
||||||
>
|
>
|
||||||
<label htmlFor="remote-pairing-words">
|
<label htmlFor="remote-pairing-words">
|
||||||
Mots affichés par le 2ᵉ appareil
|
Mots affichés par le 2ᵉ appareil
|
||||||
@ -182,18 +160,8 @@ export function PairingSetupBlock(): JSX.Element {
|
|||||||
value={remoteWordsInput}
|
value={remoteWordsInput}
|
||||||
onChange={setRemoteWordsInput}
|
onChange={setRemoteWordsInput}
|
||||||
aria-describedby={remoteError !== null ? 'remote-words-err' : undefined}
|
aria-describedby={remoteError !== null ? 'remote-words-err' : undefined}
|
||||||
aria-label="Saisir les 8 mots du 2e appareil"
|
aria-label="Saisir les 17 mots du 2e appareil"
|
||||||
/>
|
wordCount={17}
|
||||||
</label>
|
|
||||||
<label htmlFor="remote-pubkey">
|
|
||||||
Clé publique du 2ᵉ appareil (hex, 66 car.)
|
|
||||||
<input
|
|
||||||
id="remote-pubkey"
|
|
||||||
type="text"
|
|
||||||
value={remotePubkeyInput}
|
|
||||||
onChange={(e) => setRemotePubkeyInput(e.target.value)}
|
|
||||||
placeholder="02… ou 03… ou 04…"
|
|
||||||
aria-describedby={remoteError !== null ? 'remote-words-err' : undefined}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{remoteError !== null && (
|
{remoteError !== null && (
|
||||||
|
|||||||
@ -7,9 +7,10 @@ interface WordInputGridProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
'aria-describedby'?: string;
|
'aria-describedby'?: string;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
|
wordCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORD_COUNT = 8;
|
const DEFAULT_WORD_COUNT = 17;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter wordlist by prefix (case-insensitive).
|
* Filter wordlist by prefix (case-insensitive).
|
||||||
@ -23,7 +24,8 @@ function filterWordlist(prefix: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WordInputGrid component with autocomplete for 8 BIP32 words.
|
* WordInputGrid component with autocomplete for BIP32 words.
|
||||||
|
* Default: 17 words for public key encoding.
|
||||||
*/
|
*/
|
||||||
export function WordInputGrid({
|
export function WordInputGrid({
|
||||||
value,
|
value,
|
||||||
@ -31,7 +33,9 @@ export function WordInputGrid({
|
|||||||
id,
|
id,
|
||||||
'aria-describedby': ariaDescribedBy,
|
'aria-describedby': ariaDescribedBy,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
|
wordCount = DEFAULT_WORD_COUNT,
|
||||||
}: WordInputGridProps): JSX.Element {
|
}: WordInputGridProps): JSX.Element {
|
||||||
|
const WORD_COUNT = wordCount;
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState<number>(-1);
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState<number>(-1);
|
||||||
@ -209,41 +213,71 @@ export function WordInputGrid({
|
|||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div style={{ position: 'relative' }}>
|
||||||
ref={(el) => {
|
<input
|
||||||
inputRefs.current[index] = el;
|
ref={(el) => {
|
||||||
}}
|
inputRefs.current[index] = el;
|
||||||
id={`${id ?? 'word'}-${index}`}
|
}}
|
||||||
type="text"
|
id={`${id ?? 'word'}-${index}`}
|
||||||
value={word}
|
type="password"
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
value={word}
|
||||||
handleInputChange(index, e.target.value);
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
}}
|
handleInputChange(index, e.target.value);
|
||||||
onKeyDown={(e) => {
|
}}
|
||||||
handleKeyDown(index, e);
|
onKeyDown={(e) => {
|
||||||
}}
|
handleKeyDown(index, e);
|
||||||
onFocus={() => {
|
}}
|
||||||
handleFocus(index);
|
onFocus={() => {
|
||||||
}}
|
handleFocus(index);
|
||||||
onBlur={handleBlur}
|
}}
|
||||||
autoComplete="off"
|
onBlur={handleBlur}
|
||||||
spellCheck={false}
|
autoComplete="off"
|
||||||
aria-describedby={
|
spellCheck={false}
|
||||||
index === 0 && ariaDescribedBy !== undefined
|
aria-describedby={
|
||||||
? ariaDescribedBy
|
index === 0 && ariaDescribedBy !== undefined
|
||||||
: undefined
|
? ariaDescribedBy
|
||||||
}
|
: undefined
|
||||||
style={{
|
}
|
||||||
width: '100%',
|
style={{
|
||||||
padding: '0.5rem',
|
width: '100%',
|
||||||
fontSize: '1rem',
|
padding: '0.5rem',
|
||||||
fontFamily: 'monospace',
|
paddingRight: '2.5rem',
|
||||||
border: '1px solid var(--color-border, #ccc)',
|
fontSize: '1rem',
|
||||||
borderRadius: '4px',
|
fontFamily: 'monospace',
|
||||||
backgroundColor: 'var(--color-background)',
|
border: '1px solid var(--color-border, #ccc)',
|
||||||
color: 'var(--color-text)',
|
borderRadius: '4px',
|
||||||
}}
|
backgroundColor: 'var(--color-background)',
|
||||||
/>
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{word.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const input = inputRefs.current[index];
|
||||||
|
if (input !== null) {
|
||||||
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '0.25rem',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.25rem',
|
||||||
|
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}`}
|
||||||
|
>
|
||||||
|
👁
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{focusedIndex === index && suggestions.length > 0 && (
|
{focusedIndex === index && suggestions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={suggestionsRef}
|
ref={suggestionsRef}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { hashStringAsync } from '../utils/canonical';
|
|||||||
import { signMessage } from '../utils/crypto';
|
import { signMessage } from '../utils/crypto';
|
||||||
import { verifyMessageSignatures } from '../utils/verification';
|
import { verifyMessageSignatures } from '../utils/verification';
|
||||||
import { idbGet, idbSet, idbRemove } from '../utils/indexedDbStorage';
|
import { idbGet, idbSet, idbRemove } from '../utils/indexedDbStorage';
|
||||||
|
import { updatePairPublicKey } from '../utils/pairing';
|
||||||
import {
|
import {
|
||||||
getMessagesChiffres,
|
getMessagesChiffres,
|
||||||
getSignatures,
|
getSignatures,
|
||||||
@ -175,7 +176,7 @@ interface PublishPairingMessageParams {
|
|||||||
relays: RelayConfig[];
|
relays: RelayConfig[];
|
||||||
message: MembreFinaliserMessage;
|
message: MembreFinaliserMessage;
|
||||||
hash: string;
|
hash: string;
|
||||||
recipientPublicKey: string;
|
recipientPublicKey?: string;
|
||||||
senderIdentity: LocalIdentity;
|
senderIdentity: LocalIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,33 +188,47 @@ async function publishPairingMessage(
|
|||||||
params: PublishPairingMessageParams,
|
params: PublishPairingMessageParams,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { relays, message, hash, recipientPublicKey, senderIdentity } = params;
|
const { relays, message, hash, recipientPublicKey, senderIdentity } = params;
|
||||||
if (recipientPublicKey === undefined || recipientPublicKey === '') {
|
// ECDH encryption is optional: if recipientPublicKey is provided, use ECDH; otherwise publish without encryption
|
||||||
throw new Error(
|
// The public key will be extracted from signatures and stored for future ECDH use
|
||||||
'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const pk = senderIdentity.privateKey;
|
|
||||||
if (pk === undefined) {
|
|
||||||
throw new Error('Clé privée requise pour chiffrer le message de pairing (ECDH).');
|
|
||||||
}
|
|
||||||
const enabled = relays.filter((r) => r.enabled);
|
const enabled = relays.filter((r) => r.enabled);
|
||||||
if (enabled.length === 0) {
|
if (enabled.length === 0) {
|
||||||
throw new Error('No enabled relays');
|
throw new Error('No enabled relays');
|
||||||
}
|
}
|
||||||
const { encrypted, iv, senderPublicKey } = await encryptPairingMessage(
|
|
||||||
message,
|
// If recipientPublicKey is provided, use ECDH encryption
|
||||||
recipientPublicKey,
|
if (recipientPublicKey !== undefined && recipientPublicKey !== '') {
|
||||||
senderIdentity,
|
const pk = senderIdentity.privateKey;
|
||||||
);
|
if (pk === undefined) {
|
||||||
const msgCle = buildMsgCle(hash, iv, senderPublicKey);
|
throw new Error('Clé privée requise pour chiffrer le message de pairing (ECDH).');
|
||||||
const msgChiffre: MsgChiffre = {
|
}
|
||||||
hash,
|
const { encrypted, iv, senderPublicKey } = await encryptPairingMessage(
|
||||||
message_chiffre: encrypted,
|
message,
|
||||||
datajson_public: message.datajson,
|
recipientPublicKey,
|
||||||
};
|
senderIdentity,
|
||||||
for (const r of enabled) {
|
);
|
||||||
await postMessageChiffre(r.endpoint, msgChiffre);
|
const msgCle = buildMsgCle(hash, iv, senderPublicKey);
|
||||||
await postKey(r.endpoint, msgCle);
|
const msgChiffre: MsgChiffre = {
|
||||||
|
hash,
|
||||||
|
message_chiffre: encrypted,
|
||||||
|
datajson_public: message.datajson,
|
||||||
|
};
|
||||||
|
for (const r of enabled) {
|
||||||
|
await postMessageChiffre(r.endpoint, msgChiffre);
|
||||||
|
await postKey(r.endpoint, msgCle);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No ECDH: publish message in clear (base64 encoded)
|
||||||
|
// The public key will be extracted from signatures and stored for future ECDH use
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
const messageChiffre = btoa(payload);
|
||||||
|
const msgChiffre: MsgChiffre = {
|
||||||
|
hash,
|
||||||
|
message_chiffre: messageChiffre,
|
||||||
|
datajson_public: message.datajson,
|
||||||
|
};
|
||||||
|
for (const r of enabled) {
|
||||||
|
await postMessageChiffre(r.endpoint, msgChiffre);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,7 +237,7 @@ interface PublishPairingMessageAndSignatureParams {
|
|||||||
message: MembreFinaliserMessage;
|
message: MembreFinaliserMessage;
|
||||||
hash: string;
|
hash: string;
|
||||||
sig: Signature;
|
sig: Signature;
|
||||||
recipientPublicKey: string;
|
recipientPublicKey?: string;
|
||||||
senderIdentity: LocalIdentity;
|
senderIdentity: LocalIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -485,7 +500,7 @@ interface RunDevice1ConfirmationParams {
|
|||||||
pairRemote: string;
|
pairRemote: string;
|
||||||
identity: LocalIdentity;
|
identity: LocalIdentity;
|
||||||
relays: RelayConfig[];
|
relays: RelayConfig[];
|
||||||
remotePublicKey: string;
|
remotePublicKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -496,10 +511,8 @@ export async function runDevice1Confirmation(
|
|||||||
params: RunDevice1ConfirmationParams,
|
params: RunDevice1ConfirmationParams,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { pairLocal, pairRemote, identity, relays, remotePublicKey } = params;
|
const { pairLocal, pairRemote, identity, relays, remotePublicKey } = params;
|
||||||
if (remotePublicKey === undefined || remotePublicKey === '') {
|
if (remotePublicKey !== undefined && remotePublicKey !== '') {
|
||||||
throw new Error(
|
// ECDH encryption enabled when remotePublicKey is provided
|
||||||
'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const { message, hash } = await createMembreFinaliserMessage(
|
const { message, hash } = await createMembreFinaliserMessage(
|
||||||
pairLocal,
|
pairLocal,
|
||||||
@ -524,12 +537,25 @@ export async function runDevice1Confirmation(
|
|||||||
const msg = messageWithHash(message, hash);
|
const msg = messageWithHash(message, hash);
|
||||||
const { valid } = verifyMessageSignatures(msg, sigs);
|
const { valid } = verifyMessageSignatures(msg, sigs);
|
||||||
if (hasTwoDistinctSignatures(valid, identity.publicKey)) {
|
if (hasTwoDistinctSignatures(valid, identity.publicKey)) {
|
||||||
await buildAndPublishPairingVersion2(
|
// Extract remote public key from signatures
|
||||||
relays,
|
const remoteSig = valid.find((s) => s.cle_publique !== identity.publicKey);
|
||||||
message,
|
const extractedRemotePublicKey = remoteSig?.cle_publique;
|
||||||
remotePublicKey,
|
|
||||||
identity,
|
// Update pair with remote public key if found
|
||||||
);
|
if (extractedRemotePublicKey !== undefined) {
|
||||||
|
updatePairPublicKey(pairRemote, extractedRemotePublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use extracted key or provided key for ECDH
|
||||||
|
const keyForEcdh = extractedRemotePublicKey ?? remotePublicKey;
|
||||||
|
if (keyForEcdh !== undefined && keyForEcdh !== '') {
|
||||||
|
await buildAndPublishPairingVersion2(
|
||||||
|
relays,
|
||||||
|
message,
|
||||||
|
keyForEcdh,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
await storePairingConfirmed(pairLocal, pairRemote, hash, 2);
|
await storePairingConfirmed(pairLocal, pairRemote, hash, 2);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -582,6 +608,16 @@ export async function runDevice2Confirmation(
|
|||||||
if (valid.length === 0) {
|
if (valid.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract remote public key from signatures (device 1's signature)
|
||||||
|
const remoteSig = valid.find((s) => s.cle_publique !== identity.publicKey);
|
||||||
|
const extractedRemotePublicKey = remoteSig?.cle_publique;
|
||||||
|
|
||||||
|
// Update pair with remote public key if found
|
||||||
|
if (extractedRemotePublicKey !== undefined) {
|
||||||
|
updatePairPublicKey(pairRemote, extractedRemotePublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
const n = generateUuid();
|
const n = generateUuid();
|
||||||
const sig = signMembreFinaliser(hash, identity, n);
|
const sig = signMembreFinaliser(hash, identity, n);
|
||||||
await publishPairingSignature(relays, sig);
|
await publishPairingSignature(relays, sig);
|
||||||
|
|||||||
@ -318,6 +318,91 @@ export function bip32WordsToUuid(words: string[]): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert public key (hex, 66 chars) to BIP32 word list.
|
||||||
|
* Public key is 33 bytes. Each word encodes 2 bytes (16 bits), so we need 17 words.
|
||||||
|
* Last word encodes only 1 byte (the 33rd byte), so we pad with 0.
|
||||||
|
*/
|
||||||
|
export function publicKeyToBip32Words(publicKey: string): string[] {
|
||||||
|
const hex = publicKey.toLowerCase().replace(/^0x/, '');
|
||||||
|
if (hex.length !== 66) {
|
||||||
|
throw new Error('Public key must be 66 hex characters');
|
||||||
|
}
|
||||||
|
const bytes = hexToBytes(hex);
|
||||||
|
const words: string[] = [];
|
||||||
|
// Encode 32 bytes as 16 words (2 bytes per word)
|
||||||
|
for (let i = 0; i < 32; i += 2) {
|
||||||
|
const byte1 = bytes[i];
|
||||||
|
const byte2 = bytes[i + 1];
|
||||||
|
if (byte1 === undefined || byte2 === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const index = (byte1 << 8) | byte2;
|
||||||
|
const wordIndex = index % BIP32_WORDLIST.length;
|
||||||
|
const word = BIP32_WORDLIST[wordIndex];
|
||||||
|
if (word === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
// Encode last byte (33rd) as a word (pad with 0 for the second byte)
|
||||||
|
const lastByte = bytes[32];
|
||||||
|
if (lastByte !== undefined) {
|
||||||
|
const index = (lastByte << 8) | 0;
|
||||||
|
const wordIndex = index % BIP32_WORDLIST.length;
|
||||||
|
const word = BIP32_WORDLIST[wordIndex];
|
||||||
|
if (word !== undefined) {
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert BIP32 word list back to public key (hex, 66 chars).
|
||||||
|
* Expects 17 words: 16 words for 32 bytes + 1 word for the last byte.
|
||||||
|
*/
|
||||||
|
export function bip32WordsToPublicKey(words: string[]): string | null {
|
||||||
|
try {
|
||||||
|
if (words.length !== 17) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (const word of words) {
|
||||||
|
const index = BIP32_WORDLIST.indexOf(word.toLowerCase());
|
||||||
|
if (index === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
indices.push(index);
|
||||||
|
}
|
||||||
|
// Reconstruct 33 bytes: 16 words for 32 bytes + 1 word for last byte
|
||||||
|
const bytes = new Uint8Array(33);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const index = indices[i];
|
||||||
|
if (index === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
bytes[i * 2] = (index >> 8) & 0xff;
|
||||||
|
bytes[i * 2 + 1] = index & 0xff;
|
||||||
|
}
|
||||||
|
// Last word encodes only the 33rd byte (second byte is padding)
|
||||||
|
const lastIndex = indices[16];
|
||||||
|
if (lastIndex === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
bytes[32] = (lastIndex >> 8) & 0xff;
|
||||||
|
|
||||||
|
const hex = bytesToHex(bytes);
|
||||||
|
// Validate it's a valid public key (starts with 02, 03, or 04)
|
||||||
|
if (!['02', '03', '04'].includes(hex.slice(0, 2))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a random UUID v4.
|
* Generate a random UUID v4.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
generateUuid,
|
generateUuid,
|
||||||
uuidToBip32Words,
|
uuidToBip32Words,
|
||||||
bip32WordsToUuid,
|
publicKeyToBip32Words,
|
||||||
|
bip32WordsToPublicKey,
|
||||||
} from './bip32';
|
} from './bip32';
|
||||||
import type { PairConfig } from '../types/identity';
|
import type { PairConfig } from '../types/identity';
|
||||||
|
|
||||||
@ -32,18 +33,23 @@ export function storePairs(pairs: PairConfig[]): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new local pair.
|
* Create a new local pair.
|
||||||
|
* Returns words encoding the identity public key (not the pair UUID).
|
||||||
*/
|
*/
|
||||||
export function createLocalPair(membresParentsUuid: string[]): {
|
export function createLocalPair(
|
||||||
|
membresParentsUuid: string[],
|
||||||
|
identityPublicKey: string,
|
||||||
|
): {
|
||||||
pair: PairConfig;
|
pair: PairConfig;
|
||||||
words: string[];
|
words: string[];
|
||||||
} {
|
} {
|
||||||
const uuid = generateUuid();
|
const uuid = generateUuid();
|
||||||
const words = uuidToBip32Words(uuid);
|
const words = publicKeyToBip32Words(identityPublicKey);
|
||||||
const pair: PairConfig = {
|
const pair: PairConfig = {
|
||||||
uuid,
|
uuid,
|
||||||
membres_parents_uuid: membresParentsUuid,
|
membres_parents_uuid: membresParentsUuid,
|
||||||
is_local: true,
|
is_local: true,
|
||||||
can_sign: true,
|
can_sign: true,
|
||||||
|
publicKey: identityPublicKey,
|
||||||
};
|
};
|
||||||
const pairs = getStoredPairs();
|
const pairs = getStoredPairs();
|
||||||
pairs.push(pair);
|
pairs.push(pair);
|
||||||
@ -53,25 +59,23 @@ export function createLocalPair(membresParentsUuid: string[]): {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a remote pair from BIP32 words.
|
* Add a remote pair from BIP32 words.
|
||||||
* remotePublicKey (hex) optional: when provided, stored for ECDH encryption of pairing messages.
|
* Words encode the remote identity public key (66 hex chars).
|
||||||
*/
|
*/
|
||||||
export function addRemotePairFromWords(
|
export function addRemotePairFromWords(
|
||||||
words: string[],
|
words: string[],
|
||||||
membresParentsUuid: string[],
|
membresParentsUuid: string[],
|
||||||
remotePublicKey?: string,
|
|
||||||
): PairConfig | null {
|
): PairConfig | null {
|
||||||
const uuid = bip32WordsToUuid(words);
|
const publicKey = bip32WordsToPublicKey(words);
|
||||||
if (uuid === null) {
|
if (publicKey === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const uuid = generateUuid();
|
||||||
const pair: PairConfig = {
|
const pair: PairConfig = {
|
||||||
uuid,
|
uuid,
|
||||||
membres_parents_uuid: membresParentsUuid,
|
membres_parents_uuid: membresParentsUuid,
|
||||||
is_local: false,
|
is_local: false,
|
||||||
can_sign: false,
|
can_sign: false,
|
||||||
...(remotePublicKey !== undefined && remotePublicKey !== ''
|
publicKey,
|
||||||
? { publicKey: remotePublicKey }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
const pairs = getStoredPairs();
|
const pairs = getStoredPairs();
|
||||||
pairs.push(pair);
|
pairs.push(pair);
|
||||||
@ -89,7 +93,7 @@ export function generatePairingWords(): string[] {
|
|||||||
return uuidToBip32Words(uuid);
|
return uuidToBip32Words(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPECTED_PAIRING_WORD_COUNT = 8;
|
const EXPECTED_PAIRING_WORD_COUNT = 17; // 17 words for public key (33 bytes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse and validate pairing words from user input (whitespace-separated).
|
* Parse and validate pairing words from user input (whitespace-separated).
|
||||||
@ -103,8 +107,8 @@ export function parseAndValidatePairingWords(text: string): string[] | null {
|
|||||||
if (words.length !== EXPECTED_PAIRING_WORD_COUNT) {
|
if (words.length !== EXPECTED_PAIRING_WORD_COUNT) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const uuid = bip32WordsToUuid(words);
|
const publicKey = bip32WordsToPublicKey(words);
|
||||||
return uuid !== null ? words : null;
|
return publicKey !== null ? words : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,15 +129,16 @@ export function hasRemotePair(): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a local pair exists for setup (1st or 2nd device). If none, create one.
|
* Ensure a local pair exists for setup (1st or 2nd device). If none, create one.
|
||||||
* Returns the local pair's words for QR/URL. Idempotent.
|
* Returns the local pair's words (encoding public key) for QR/URL. Idempotent.
|
||||||
|
* Requires identity public key.
|
||||||
*/
|
*/
|
||||||
export function ensureLocalPairForSetup(): string[] {
|
export function ensureLocalPairForSetup(identityPublicKey: string): string[] {
|
||||||
const pairs = getStoredPairs();
|
const pairs = getStoredPairs();
|
||||||
const local = pairs.find((p) => p.is_local);
|
const local = pairs.find((p) => p.is_local);
|
||||||
if (local !== undefined) {
|
if (local !== undefined && local.publicKey !== undefined) {
|
||||||
return uuidToBip32Words(local.uuid);
|
return publicKeyToBip32Words(local.publicKey);
|
||||||
}
|
}
|
||||||
const { words } = createLocalPair([]);
|
const { words } = createLocalPair([], identityPublicKey);
|
||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,16 +151,16 @@ export function getPairsForMember(membreUuid: string): PairConfig[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Words for the local pair (8 words, BIP32-style). Null if no local pair.
|
* Words for the local pair (encoding public key, BIP32-style). Null if no local pair or no public key.
|
||||||
* Does not create a pair.
|
* Does not create a pair.
|
||||||
*/
|
*/
|
||||||
export function getLocalPairWords(): string[] | null {
|
export function getLocalPairWords(): string[] | null {
|
||||||
const pairs = getStoredPairs();
|
const pairs = getStoredPairs();
|
||||||
const local = pairs.find((p) => p.is_local);
|
const local = pairs.find((p) => p.is_local);
|
||||||
if (local === undefined) {
|
if (local === undefined || local.publicKey === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return uuidToBip32Words(local.uuid);
|
return publicKeyToBip32Words(local.publicKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -165,3 +170,25 @@ export function getLocalPairWords(): string[] | null {
|
|||||||
export function isPairingConnected(): boolean {
|
export function isPairingConnected(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a pair by UUID.
|
||||||
|
*/
|
||||||
|
export function removePair(pairUuid: string): void {
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
const filtered = pairs.filter((p) => p.uuid !== pairUuid);
|
||||||
|
storePairs(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a pair's public key (for ECDH encryption).
|
||||||
|
*/
|
||||||
|
export function updatePairPublicKey(pairUuid: string, publicKey: string): void {
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
const pair = pairs.find((p) => p.uuid === pairUuid);
|
||||||
|
if (pair === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pair.publicKey = publicKey;
|
||||||
|
storePairs(pairs);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# website-skeleton
|
# website-skeleton
|
||||||
|
|
||||||
Squelette d'un site qui intègre UserWallet en iframe : écoute des messages `postMessage` (auth-request, login-proof, error, contract), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé).
|
Squelette d'un site qui intègre UserWallet en iframe : écoute des messages `postMessage` (login-proof, error, contract), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé). Connexion via un seul parcours : « Se connecter » puis authentification MFA dans l'iframe.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ Ouvre par défaut sur `http://localhost:3024`. L'iframe pointe vers UserWallet (
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d'environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`).
|
- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d'environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`).
|
||||||
- **Contrat de service** : Le skeleton a un contrat de service réel défini dans `src/serviceContract.ts` avec UUID `skeleton-service-uuid-4nkweb-2026`. Le contrat est chargé automatiquement au démarrage et envoyé à l'iframe UserWallet.
|
- **Contrat de service** : Le skeleton a un contrat de service réel défini dans `src/serviceContract.ts` avec UUID `32b9095a-562d-4239-ae45-2d7ffb1a40de`. Le contrat est chargé automatiquement au démarrage et envoyé à l'iframe UserWallet.
|
||||||
- **Clé publique du service** : Configurez `VITE_SKELETON_SERVICE_PUBLIC_KEY` avec une clé publique secp256k1 compressée valide (66 hex chars, commençant par 02 ou 03). Exemple : `VITE_SKELETON_SERVICE_PUBLIC_KEY=02abc123... npm run dev`. Si non configurée, un placeholder est utilisé mais les signatures ne pourront pas être vérifiées.
|
- **Clé publique du service** : Configurez `VITE_SKELETON_SERVICE_PUBLIC_KEY` avec une clé publique secp256k1 compressée valide (66 hex chars, commençant par 02 ou 03). Exemple : `VITE_SKELETON_SERVICE_PUBLIC_KEY=02abc123... npm run dev`. Si non configurée, un placeholder est utilisé mais les signatures ne pourront pas être vérifiées.
|
||||||
- **Validateurs** : Les validateurs sont extraits automatiquement du contrat de service skeleton. Le skeleton peut aussi recevoir un contrat personnalisé via `postMessage` (type `contract`) qui remplacera le contrat par défaut.
|
- **Validateurs** : Les validateurs sont extraits automatiquement du contrat de service skeleton. Le skeleton peut aussi recevoir un contrat personnalisé via `postMessage` (type `contract`) qui remplacera le contrat par défaut.
|
||||||
|
|
||||||
@ -64,8 +64,10 @@ Le skeleton utilise automatiquement le contrat de service skeleton au démarrage
|
|||||||
2. Lancer le skeleton (`npm run dev` ou servir `dist/`).
|
2. Lancer le skeleton (`npm run dev` ou servir `dist/`).
|
||||||
3. Ouvrir la page du skeleton : l'iframe affiche UserWallet.
|
3. Ouvrir la page du skeleton : l'iframe affiche UserWallet.
|
||||||
4. **Envoyer contrat (optionnel)** : envoyer un message `contract` avec le contrat et ses actions pour mettre à jour les validateurs.
|
4. **Envoyer contrat (optionnel)** : envoyer un message `contract` avec le contrat et ses actions pour mettre à jour les validateurs.
|
||||||
5. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l'iframe.
|
5. **Se connecter** : cliquer « Se connecter » → envoi du contrat à l'iframe, affichage de l'iframe.
|
||||||
6. **Login** : depuis l'iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
|
6. **Login MFA** : depuis l'iframe, effectuer le flux de login (MFA) ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
|
||||||
|
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).
|
||||||
|
|
||||||
## Exemple d'intégration
|
## Exemple d'intégration
|
||||||
|
|
||||||
@ -165,8 +167,10 @@ Les raisons de refus possibles :
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `index.html` : page avec iframe, zone de statut, bouton auth.
|
- `index.html` : page avec iframe, liens « Description du contrat » / « Description du membre », bouton « Se connecter ».
|
||||||
- `src/main.ts` : chargement de l'iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut, gestion des messages `contract`.
|
- `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.
|
||||||
- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS` (extraits du contrat skeleton), types `Contrat` et `Action`.
|
- `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/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`).
|
- `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`).
|
||||||
|
|||||||
159
website-skeleton/contrat.html
Normal file
159
website-skeleton/contrat.html
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Le contrat – 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; }
|
||||||
|
h4 { font-size: 0.95rem; margin-top: 0.75rem; margin-bottom: 0.25rem; font-weight: 600; color: #555; }
|
||||||
|
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; }
|
||||||
|
.commitment { background: #d4edda; padding: 1rem; border-radius: 8px; border-left: 4px solid #28a745; margin: 1rem 0; }
|
||||||
|
.your-part { background: #fff3cd; padding: 1rem; border-radius: 8px; border-left: 4px solid #ffc107; 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; }
|
||||||
|
.member-list { list-style: none; padding-left: 0; }
|
||||||
|
.member-list > li { background: #f8f9fa; padding: 0.75rem 1rem; border-radius: 8px; border: 1px solid #e0e0e0; margin: 0.5rem 0; }
|
||||||
|
.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; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body { padding: 0.75rem; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Le contrat</h1>
|
||||||
|
<p><a href="index.html">← Retour à l'accueil</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
|
||||||
|
|
||||||
|
<div class="highlight">
|
||||||
|
<strong>En résumé :</strong> Ce contrat définit les règles de confiance entre vous et le service.
|
||||||
|
Il précise <strong>qui peut faire quoi</strong> et <strong>comment prouver son identité</strong>.
|
||||||
|
Tout est vérifiable et transparent.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Qu'est-ce que ce contrat ?</h2>
|
||||||
|
<p>
|
||||||
|
Ce n'est pas un contrat papier, mais un <strong>accord numérique</strong> qui établit les règles du jeu.
|
||||||
|
Il définit :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Le service</strong> : Website Skeleton (ce site de démonstration).</li>
|
||||||
|
<li><strong>Les actions possibles</strong> : se connecter (login).</li>
|
||||||
|
<li><strong>Les validateurs</strong> : qui a le droit de vérifier les connexions.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Ce que le service s'engage à faire</h2>
|
||||||
|
<div class="commitment">
|
||||||
|
<ul>
|
||||||
|
<li>✓ <strong>Ne jamais stocker vos clés privées</strong> — elles restent sur votre appareil.</li>
|
||||||
|
<li>✓ <strong>Vérifier votre identité de façon transparente</strong> — via une preuve cryptographique que vous fournissez.</li>
|
||||||
|
<li>✓ <strong>Respecter les règles du contrat</strong> — publiquement vérifiables.</li>
|
||||||
|
<li>✓ <strong>Utiliser une clé de service déclarée</strong> — jamais exposée, mais vérifiable.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Ce que vous vous engagez à faire</h2>
|
||||||
|
<div class="your-part">
|
||||||
|
<ul>
|
||||||
|
<li>✓ <strong>Protéger votre appareil</strong> — c'est votre coffre-fort numérique.</li>
|
||||||
|
<li>✓ <strong>Fournir une preuve valide</strong> — en signant avec vos clés lors de la connexion.</li>
|
||||||
|
<li>✓ <strong>Être responsable de vos clés</strong> — si vous les perdez, personne ne peut les récupérer.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Les parties prenantes</h2>
|
||||||
|
|
||||||
|
<h3>Le service (Website Skeleton)</h3>
|
||||||
|
<p>
|
||||||
|
C'est le site que vous utilisez. Il possède sa propre identité (un « validateur ») qui permet
|
||||||
|
de vérifier que les connexions sont légitimes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Vous (le membre connecté)</h3>
|
||||||
|
<p>
|
||||||
|
Vous êtes l'utilisateur qui souhaite accéder au service. Votre identité est prouvée par
|
||||||
|
la signature de votre appareil (<a href="membre.html">en savoir plus</a>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Comment fonctionne la validation ?</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Vous demandez à vous connecter.</li>
|
||||||
|
<li>Votre appareil crée une <strong>preuve</strong> (login-proof) signée avec vos clés.</li>
|
||||||
|
<li>Le service vérifie cette preuve grâce aux règles définies dans ce contrat.</li>
|
||||||
|
<li>Si la preuve est valide, vous êtes connecté. Sinon, l'accès est refusé.</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
Ce système est <strong>plus sûr</strong> qu'un mot de passe classique car il n'y a rien à voler côté serveur.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Pourquoi c'est plus sûr ?</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Pas de base de mots de passe</strong> à pirater.</li>
|
||||||
|
<li><strong>Vos clés ne transitent jamais</strong> sur le réseau.</li>
|
||||||
|
<li><strong>Chaque connexion est unique</strong> (signature à usage unique).</li>
|
||||||
|
<li><strong>Vérifiable par tous</strong> — les règles du contrat sont publiques.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Détails techniques du contrat</summary>
|
||||||
|
<h3>Identifiants</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Contrat UUID : <span class="meta">f9b9b336-4282-4c1c-b70b-e5197aeae3fa</span></li>
|
||||||
|
<li>Service UUID : <span class="meta">32b9095a-562d-4239-ae45-2d7ffb1a40de</span></li>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h4>Validateur (contrat)</h4>
|
||||||
|
<ul class="member-list">
|
||||||
|
<li>
|
||||||
|
<strong>Membre</strong> : <span class="meta">0e865301-362f-4951-bfbc-531b7bddf820</span>
|
||||||
|
<br><em>Signatures obligatoires : 1</em>
|
||||||
|
<ul class="pair-list">
|
||||||
|
<li>
|
||||||
|
<strong>Pair 1</strong> : <span class="meta">f2779304-0d9b-4139-9aee-8d3347819d98</span>
|
||||||
|
<br>Clé publique : <span class="meta">0244f299538f4a091d93561dcee0c77de3e0d8bb917c9378405653c57f7800f174</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Validateur login (action)</h4>
|
||||||
|
<ul class="member-list">
|
||||||
|
<li>
|
||||||
|
<strong>Membre</strong> : <span class="meta">0e865301-362f-4951-bfbc-531b7bddf820</span>
|
||||||
|
<br><em>Signatures obligatoires : 1</em>
|
||||||
|
<ul class="pair-list">
|
||||||
|
<li>
|
||||||
|
<strong>Pair 1</strong> : <span class="meta">f2779304-0d9b-4139-9aee-8d3347819d98</span>
|
||||||
|
<br>Clé publique : <span class="meta">0244f299538f4a091d93561dcee0c77de3e0d8bb917c9378405653c57f7800f174</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p><a href="index.html">← Retour à l'accueil</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -45,7 +45,7 @@ if (existsSync(envPath)) {
|
|||||||
// Write .env with public key
|
// Write .env with public key
|
||||||
const envContent = `# Service wallet public key for website-skeleton
|
const envContent = `# Service wallet public key for website-skeleton
|
||||||
# Generated on ${new Date().toISOString()}
|
# Generated on ${new Date().toISOString()}
|
||||||
# Service UUID: skeleton-service-uuid-4nkweb-2026
|
# Service UUID: 32b9095a-562d-4239-ae45-2d7ffb1a40de
|
||||||
VITE_SKELETON_SERVICE_PUBLIC_KEY=${publicKey}
|
VITE_SKELETON_SERVICE_PUBLIC_KEY=${publicKey}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ writeFileSync(envPath, envContent, { mode: 0o600 });
|
|||||||
const envPrivateContent = `# Service wallet private key for website-skeleton
|
const envPrivateContent = `# Service wallet private key for website-skeleton
|
||||||
# ⚠️ SECRET: Keep this file secure and never commit it to version control
|
# ⚠️ SECRET: Keep this file secure and never commit it to version control
|
||||||
# Generated on ${new Date().toISOString()}
|
# Generated on ${new Date().toISOString()}
|
||||||
# Service UUID: skeleton-service-uuid-4nkweb-2026
|
# Service UUID: 32b9095a-562d-4239-ae45-2d7ffb1a40de
|
||||||
#
|
#
|
||||||
# This private key is used to sign service operations.
|
# This private key is used to sign service operations.
|
||||||
# Store it securely and never share it.
|
# Store it securely and never share it.
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Website skeleton – UserWallet iframe</title>
|
<title>4NK un nouveau web - site d'exemple</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -19,28 +19,6 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
#status {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
#status.accepted {
|
|
||||||
background: #d4edda;
|
|
||||||
border-color: #c3e6cb;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
#status.rejected {
|
|
||||||
background: #f8d7da;
|
|
||||||
border-color: #f5c6cb;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
#status.pending {
|
|
||||||
background: #fff3cd;
|
|
||||||
border-color: #ffeaa7;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
.button-group {
|
.button-group {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -83,18 +61,46 @@
|
|||||||
border-color: #c82333;
|
border-color: #c82333;
|
||||||
}
|
}
|
||||||
#iframe-container {
|
#iframe-container {
|
||||||
margin: 1rem 0;
|
margin: 1.5rem 0;
|
||||||
min-height: 400px;
|
border-radius: 12px;
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f9f9f9;
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.iframe-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.iframe-header-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.iframe-header-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.iframe-header-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
#iframe-container iframe {
|
#iframe-container iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 600px;
|
height: 550px;
|
||||||
border: 0;
|
border: 0;
|
||||||
display: block;
|
display: block;
|
||||||
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
#connected-section {
|
#connected-section {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@ -117,9 +123,19 @@
|
|||||||
h1 {
|
h1 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
#iframe-container {
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
#iframe-container iframe {
|
#iframe-container iframe {
|
||||||
height: 500px;
|
height: 500px;
|
||||||
}
|
}
|
||||||
|
.iframe-header {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
.iframe-header-subtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@ -130,21 +146,25 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
#iframe-container iframe {
|
#iframe-container iframe {
|
||||||
height: 400px;
|
height: 450px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Website skeleton – intégration iframe UserWallet</h1>
|
<h1>4NK un nouveau web - site d'exemple</h1>
|
||||||
<p id="status">En attente du login depuis l'iframe.</p>
|
|
||||||
|
|
||||||
<div id="login-section">
|
<div id="login-section">
|
||||||
|
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button type="button" id="btn-login" class="primary">Se connecter</button>
|
<button type="button" id="btn-login" class="primary">Se connecter</button>
|
||||||
<button type="button" id="btn-auth">Demander auth (auth-request)</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="iframe-container">
|
<div id="iframe-container" style="display: none;">
|
||||||
|
<div class="iframe-header">
|
||||||
|
<div class="iframe-header-icon">🔐</div>
|
||||||
|
<span class="iframe-header-title">UserWallet</span>
|
||||||
|
<span class="iframe-header-subtitle">Connexion sécurisée</span>
|
||||||
|
</div>
|
||||||
<iframe id="userwallet" title="UserWallet"></iframe>
|
<iframe id="userwallet" title="UserWallet"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -152,6 +172,7 @@
|
|||||||
<div id="connected-section" style="display: none;">
|
<div id="connected-section" style="display: none;">
|
||||||
<h2>Vous êtes connecté</h2>
|
<h2>Vous êtes connecté</h2>
|
||||||
<div id="user-info"></div>
|
<div id="user-info"></div>
|
||||||
|
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button type="button" id="btn-logout" class="danger">Se déconnecter</button>
|
<button type="button" id="btn-logout" class="danger">Se déconnecter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
182
website-skeleton/membre.html
Normal file
182
website-skeleton/membre.html
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Qui êtes-vous ? – 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; }
|
||||||
|
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; }
|
||||||
|
.warning { background: #fff3cd; padding: 1rem; border-radius: 8px; border-left: 4px solid #ffc107; 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; }
|
||||||
|
details h3 { font-size: 0.95rem; margin-top: 1rem; margin-bottom: 0.5rem; font-weight: 600; color: #555; }
|
||||||
|
#user-pairs-info ul { list-style: none; padding-left: 0; }
|
||||||
|
#user-pairs-info li { background: #f9f9f9; padding: 0.75rem; border-radius: 6px; margin: 0.5rem 0; border: 1px solid #e0e0e0; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body { padding: 0.75rem; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="highlight">
|
||||||
|
<strong>En résumé :</strong> Vous êtes un <strong>membre</strong> qui peut avoir plusieurs appareils (Pairs).
|
||||||
|
Chaque appareil possède ses propres clés et peut signer selon <strong>vos règles</strong>.
|
||||||
|
Vos données sont stockées selon les membres du contrat — vous gardez le contrôle total.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Vous êtes le « membre connecté »</h2>
|
||||||
|
<p>
|
||||||
|
Quand vous vous connectez à ce service, vous devenez un <strong>membre</strong>.
|
||||||
|
Contrairement aux sites classiques où vos identifiants sont stockés sur un serveur distant,
|
||||||
|
ici votre identité reste <strong>chez vous</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Un membre = plusieurs appareils</h2>
|
||||||
|
<p>
|
||||||
|
Un membre n'est pas limité à un seul appareil. Vous pouvez avoir <strong>plusieurs appareils</strong>
|
||||||
|
(ordinateur, téléphone, tablette) qui forment ensemble votre identité :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Chaque appareil s'appelle un « <strong>Pair</strong> » (device).</li>
|
||||||
|
<li>Chaque Pair possède <strong>sa propre paire de clés</strong> cryptographiques.</li>
|
||||||
|
<li>Tous vos Pairs peuvent signer en votre nom, selon les règles que <strong>vous</strong> définissez.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<em>Exemple :</em> Vous pouvez configurer votre ordinateur principal et votre téléphone comme deux Pairs.
|
||||||
|
Si l'un est perdu, vous gardez l'accès via l'autre.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Vous définissez les règles</h2>
|
||||||
|
<p>
|
||||||
|
Chaque membre a un <strong>contrat</strong> qui définit les règles de signature et de validation.
|
||||||
|
C'est <strong>vous</strong> qui contrôlez ces règles :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Quels Pairs peuvent signer</strong> — vous décidez quels appareils sont autorisés.</li>
|
||||||
|
<li><strong>Combien de signatures sont requises</strong> — une seule, ou plusieurs pour plus de sécurité.</li>
|
||||||
|
<li><strong>Pour quelles actions</strong> — certaines actions peuvent nécessiter plus de validations.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="highlight">
|
||||||
|
<strong>Exemple :</strong> Vous pouvez exiger qu'une action sensible (comme un paiement) soit signée
|
||||||
|
par <strong>2 de vos 3 appareils</strong> — c'est le principe du « multi-signature ».
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Où sont stockées vos données ?</h2>
|
||||||
|
<p>
|
||||||
|
Les données du service sont stockées selon les <strong>membres définis dans le contrat</strong>.
|
||||||
|
Chaque membre a ses propres données, séparées des autres :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Vos clés privées</strong> — sur vos appareils (Pairs), jamais sur le serveur.</li>
|
||||||
|
<li><strong>Vos données utilisateur</strong> — associées à votre identité de membre.</li>
|
||||||
|
<li><strong>Les preuves de signature</strong> — vérifiables publiquement, liées à vos Pairs.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Votre appareil = votre coffre-fort</h2>
|
||||||
|
<p>
|
||||||
|
Chaque appareil (Pair) joue le rôle de <strong>coffre-fort numérique</strong> :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Vos clés de sécurité</strong> sont créées directement dans votre navigateur (dans la fenêtre de connexion).</li>
|
||||||
|
<li><strong>Elles ne quittent jamais votre appareil</strong> — elles sont stockées localement (IndexedDB).</li>
|
||||||
|
<li><strong>Personne d'autre n'y a accès</strong>, pas même le service.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Comment ça fonctionne ?</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Vous cliquez sur « Se connecter ».</li>
|
||||||
|
<li>Une fenêtre s'ouvre (UserWallet) où vous déverrouillez votre identité.</li>
|
||||||
|
<li>Votre appareil <strong>signe</strong> une preuve que c'est bien vous (comme une signature manuscrite, mais numérique).</li>
|
||||||
|
<li>Le service vérifie cette preuve et vous donne accès.</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
À aucun moment vos clés secrètes ne sont transmises — seule la <strong>preuve</strong> de votre identité l'est.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Important :</strong> Si vous perdez l'accès à votre appareil (panne, vol, perte),
|
||||||
|
vous perdez vos clés. Pensez à configurer un second appareil ou une sauvegarde.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Quelle différence avec un mot de passe classique ?</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Mot de passe classique</strong> : stocké sur le serveur du site → risque de fuite en cas de piratage.</li>
|
||||||
|
<li><strong>Ici</strong> : vos clés restent sur votre appareil → même si le service est piraté, vos clés sont en sécurité.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Détails techniques (pour les curieux)</summary>
|
||||||
|
<ul>
|
||||||
|
<li>Vos clés utilisent la cryptographie <strong>secp256k1</strong> (la même que Bitcoin).</li>
|
||||||
|
<li>Elles sont stockées dans <strong>IndexedDB</strong> de votre navigateur.</li>
|
||||||
|
<li>La connexion utilise l'authentification multi-facteur (<strong>MFA</strong>).</li>
|
||||||
|
<li>Le service possède son propre portefeuille (wallet) séparé du vôtre, jamais exposé.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Vos Pairs et clés publiques</h3>
|
||||||
|
<div id="user-pairs-info">
|
||||||
|
<p><em>Non connecté — connectez-vous pour voir vos Pairs.</em></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const SESSION_STORAGE_KEY = 'website-skeleton-session';
|
||||||
|
const container = document.getElementById('user-pairs-info');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
if (!stored) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = JSON.parse(stored);
|
||||||
|
if (!session || !session.proof || !session.proof.signatures) return;
|
||||||
|
|
||||||
|
const signatures = session.proof.signatures;
|
||||||
|
if (signatures.length === 0) {
|
||||||
|
container.innerHTML = '<p><em>Aucun Pair enregistré.</em></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul>';
|
||||||
|
signatures.forEach(function(sig, index) {
|
||||||
|
const pairUuid = sig.pair_uuid || 'Non spécifié';
|
||||||
|
const pubKey = sig.cle_publique || 'Non disponible';
|
||||||
|
html += '<li>';
|
||||||
|
html += '<strong>Pair ' + (index + 1) + '</strong><br>';
|
||||||
|
html += 'UUID : <span class="meta">' + pairUuid + '</span><br>';
|
||||||
|
html += 'Clé publique : <span class="meta">' + pubKey + '</span>';
|
||||||
|
html += '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
// Session parsing error - keep default message
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p><a href="index.html">← Retour à l'accueil</a> · <a href="contrat.html">Voir le contrat</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -14,8 +14,6 @@ import { getSkeletonServiceContractData } from './serviceContract.js';
|
|||||||
import type { Contrat, Action } from './config.js';
|
import type { Contrat, Action } from './config.js';
|
||||||
|
|
||||||
const iframe = document.getElementById('userwallet') as HTMLIFrameElement;
|
const iframe = document.getElementById('userwallet') as HTMLIFrameElement;
|
||||||
const statusEl = document.getElementById('status') as HTMLParagraphElement;
|
|
||||||
const btnAuth = document.getElementById('btn-auth') as HTMLButtonElement;
|
|
||||||
const btnLogin = document.getElementById('btn-login') as HTMLButtonElement;
|
const btnLogin = document.getElementById('btn-login') as HTMLButtonElement;
|
||||||
const btnLogout = document.getElementById('btn-logout') as HTMLButtonElement;
|
const btnLogout = document.getElementById('btn-logout') as HTMLButtonElement;
|
||||||
const iframeContainer = document.getElementById('iframe-container') as HTMLDivElement;
|
const iframeContainer = document.getElementById('iframe-container') as HTMLDivElement;
|
||||||
@ -70,13 +68,6 @@ function isLoggedIn(): boolean {
|
|||||||
return getSession() !== null;
|
return getSession() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(text: string, kind: 'pending' | 'accepted' | 'rejected'): void {
|
|
||||||
if (statusEl !== null) {
|
|
||||||
statusEl.textContent = text;
|
|
||||||
statusEl.className = kind === 'accepted' ? 'accepted' : kind === 'rejected' ? 'rejected' : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoginInterface(): void {
|
function showLoginInterface(): void {
|
||||||
if (loginSection !== null) {
|
if (loginSection !== null) {
|
||||||
loginSection.style.display = 'block';
|
loginSection.style.display = 'block';
|
||||||
@ -85,7 +76,7 @@ function showLoginInterface(): void {
|
|||||||
connectedSection.style.display = 'none';
|
connectedSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (iframeContainer !== null) {
|
if (iframeContainer !== null) {
|
||||||
iframeContainer.style.display = 'block';
|
iframeContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,36 +122,19 @@ function sendContractToIframe(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAuthRequest(): void {
|
|
||||||
if (iframe?.contentWindow == null) {
|
|
||||||
setStatus('Iframe non prêt.', 'rejected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
iframe.contentWindow.postMessage(
|
|
||||||
{ type: 'auth-request', payload: { serviceId: 'skeleton' } },
|
|
||||||
USERWALLET_ORIGIN,
|
|
||||||
);
|
|
||||||
setStatus('Auth demandé (auth-request envoyé).', 'pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogin(): void {
|
function handleLogin(): void {
|
||||||
if (iframe?.contentWindow == null) {
|
if (iframe?.contentWindow == null) {
|
||||||
setStatus('Iframe non prêt.', 'rejected');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Send contract if available
|
|
||||||
sendContractToIframe();
|
sendContractToIframe();
|
||||||
// Show iframe
|
|
||||||
if (iframeContainer !== null) {
|
if (iframeContainer !== null) {
|
||||||
iframeContainer.style.display = 'block';
|
iframeContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
setStatus('Connexion en cours...', 'pending');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout(): void {
|
function handleLogout(): void {
|
||||||
clearSession();
|
clearSession();
|
||||||
updateUI();
|
updateUI();
|
||||||
setStatus('Déconnecté.', 'pending');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -181,21 +155,12 @@ function updateValidatorsFromContract(
|
|||||||
if (validateurs !== null) {
|
if (validateurs !== null) {
|
||||||
currentValidateurs = validateurs;
|
currentValidateurs = validateurs;
|
||||||
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
|
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
|
||||||
setStatus('Contrat reçu et validateurs mis à jour.', 'pending');
|
|
||||||
// Send contract to iframe if it's ready
|
|
||||||
sendContractToIframe();
|
|
||||||
} else {
|
|
||||||
setStatus(
|
|
||||||
'Contrat reçu mais validateurs login introuvables. Le login ne pourra pas être vérifié.',
|
|
||||||
'rejected',
|
|
||||||
);
|
|
||||||
// Still send contract to iframe even if validators not found
|
|
||||||
sendContractToIframe();
|
|
||||||
}
|
}
|
||||||
|
sendContractToIframe();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent): void {
|
function handleMessage(msg: MessageEvent): void {
|
||||||
const d = event.data;
|
const d = msg.data;
|
||||||
if (d?.type === undefined) {
|
if (d?.type === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -224,8 +189,6 @@ function handleMessage(event: MessageEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateValidatorsFromContract(payload.contrat, contratsFils, actions);
|
updateValidatorsFromContract(payload.contrat, contratsFils, actions);
|
||||||
} else {
|
|
||||||
setStatus('Message contrat reçu mais structure invalide.', 'rejected');
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -239,37 +202,25 @@ function handleMessage(event: MessageEvent): void {
|
|||||||
});
|
});
|
||||||
if (result.accept) {
|
if (result.accept) {
|
||||||
setSession(proof);
|
setSession(proof);
|
||||||
setStatus('Login accepté. Session ouverte.', 'accepted');
|
|
||||||
updateUI();
|
updateUI();
|
||||||
} else {
|
|
||||||
setStatus(`Login refusé: ${result.reason ?? 'inconnu'}`, 'rejected');
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (d.type === 'auth-response') {
|
|
||||||
setStatus('Auth reçu (auth-response).', 'pending');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (d.type === 'error') {
|
if (d.type === 'error') {
|
||||||
const msg = (d.payload as { message?: string })?.message ?? 'Erreur';
|
|
||||||
setStatus(`Erreur iframe: ${msg}`, 'rejected');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(): void {
|
function init(): void {
|
||||||
if (iframe == null || statusEl === null) {
|
if (iframe == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
iframe.src = USERWALLET_ORIGIN;
|
iframe.src = USERWALLET_ORIGIN;
|
||||||
btnAuth?.addEventListener('click', sendAuthRequest);
|
|
||||||
btnLogin?.addEventListener('click', handleLogin);
|
btnLogin?.addEventListener('click', handleLogin);
|
||||||
btnLogout?.addEventListener('click', handleLogout);
|
btnLogout?.addEventListener('click', handleLogout);
|
||||||
window.addEventListener('message', handleMessage);
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
// Initialize validators from skeleton service contract
|
|
||||||
const validateurs = extractLoginValidators(
|
const validateurs = extractLoginValidators(
|
||||||
storedContract ?? skeletonContractData.contrat,
|
storedContract ?? skeletonContractData.contrat,
|
||||||
storedContratsFils,
|
storedContratsFils,
|
||||||
@ -280,28 +231,11 @@ function init(): void {
|
|||||||
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
|
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send contract to iframe when it's loaded
|
|
||||||
iframe.addEventListener('load', () => {
|
iframe.addEventListener('load', () => {
|
||||||
sendContractToIframe();
|
sendContractToIframe();
|
||||||
});
|
});
|
||||||
// Check if already logged in
|
|
||||||
if (isLoggedIn()) {
|
updateUI();
|
||||||
updateUI();
|
|
||||||
setStatus('Session active.', 'accepted');
|
|
||||||
} else {
|
|
||||||
updateUI();
|
|
||||||
if (storedContract !== null) {
|
|
||||||
setStatus(
|
|
||||||
'Contrat de service skeleton chargé. Prêt pour le login.',
|
|
||||||
'pending',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setStatus(
|
|
||||||
'En attente du login. Un contrat avec validateurs login doit être reçu pour vérifier les preuves.',
|
|
||||||
'pending',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@ -1,25 +1,39 @@
|
|||||||
import type { Contrat, Action } from './config.js';
|
import type { Contrat, Action } from './config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service UUID for website-skeleton.
|
* Service UUID for website-skeleton (UUID v4).
|
||||||
* This is a real service contract for the skeleton service.
|
|
||||||
*/
|
*/
|
||||||
export const SKELETON_SERVICE_UUID = 'skeleton-service-uuid-4nkweb-2026';
|
export const SKELETON_SERVICE_UUID = '32b9095a-562d-4239-ae45-2d7ffb1a40de';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contract UUID for website-skeleton.
|
* Contract UUID for website-skeleton (UUID v4).
|
||||||
*/
|
*/
|
||||||
export const SKELETON_CONTRACT_UUID = 'skeleton-contract-uuid-4nkweb-2026';
|
export const SKELETON_CONTRACT_UUID = 'f9b9b336-4282-4c1c-b70b-e5197aeae3fa';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login action UUID for website-skeleton.
|
* Login action UUID for website-skeleton (UUID v4).
|
||||||
*/
|
*/
|
||||||
export const SKELETON_LOGIN_ACTION_UUID = 'skeleton-login-action-uuid-4nkweb-2026';
|
export const SKELETON_LOGIN_ACTION_UUID = '0ac7de59-9e81-4bdc-bd19-c07750fad48e';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Member UUID for website-skeleton validators.
|
* Member UUID for website-skeleton validators (UUID v4).
|
||||||
*/
|
*/
|
||||||
export const SKELETON_MEMBER_UUID = 'skeleton-member-uuid-4nkweb-2026';
|
export const SKELETON_MEMBER_UUID = '0e865301-362f-4951-bfbc-531b7bddf820';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair (device) UUID for the service member (UUID v4).
|
||||||
|
*/
|
||||||
|
export const SKELETON_SERVICE_PAIR_UUID = 'f2779304-0d9b-4139-9aee-8d3347819d98';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract type UUID (UUID v4).
|
||||||
|
*/
|
||||||
|
const SKELETON_CONTRACT_TYPE_UUID = '4ee812da-4243-4027-baeb-2bfbdd977a4f';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action type UUID (UUID v4).
|
||||||
|
*/
|
||||||
|
const SKELETON_ACTION_TYPE_UUID = 'fdb638c2-357a-4da2-bda1-966c6ca0de9d';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get skeleton service contract with current public key.
|
* Get skeleton service contract with current public key.
|
||||||
@ -44,7 +58,7 @@ function getSkeletonServiceContract(): Contrat {
|
|||||||
datajson: {
|
datajson: {
|
||||||
types_names_chiffres: 'contrat',
|
types_names_chiffres: 'contrat',
|
||||||
services_uuid: [SKELETON_SERVICE_UUID],
|
services_uuid: [SKELETON_SERVICE_UUID],
|
||||||
types_uuid: ['skeleton-contract-type-uuid'],
|
types_uuid: [SKELETON_CONTRACT_TYPE_UUID],
|
||||||
label: 'Contrat de service Website Skeleton',
|
label: 'Contrat de service Website Skeleton',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -73,7 +87,7 @@ function getSkeletonLoginAction(): Action {
|
|||||||
datajson: {
|
datajson: {
|
||||||
types_names_chiffres: 'action,login',
|
types_names_chiffres: 'action,login',
|
||||||
services_uuid: [SKELETON_SERVICE_UUID],
|
services_uuid: [SKELETON_SERVICE_UUID],
|
||||||
types_uuid: ['skeleton-action-type-uuid'],
|
types_uuid: [SKELETON_ACTION_TYPE_UUID],
|
||||||
label: 'Action login Website Skeleton',
|
label: 'Action login Website Skeleton',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: 'index.html',
|
input: ['index.html', 'contrat.html', 'membre.html'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Website skeleton (UserWallet iframe integration)
|
Description=4NK un nouveau web - site d'exemple
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user