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:
ncantu 2026-01-28 17:28:50 +01:00
parent 97242d5dab
commit 497bcf0819
23 changed files with 1233 additions and 490 deletions

View File

@ -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...
💾 Mise à jour des UTXOs dépensés...
@ -98,3 +12,89 @@
- Non dépensés: 5091
✅ 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

View 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

View File

@ -65,6 +65,12 @@ Ce dossier contient toute la documentation nécessaire pour la maintenance et l'
- Configuration (origine UserWallet, validateurs)
- 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
### Installation

View File

@ -1,6 +1,6 @@
# website-skeleton
Squelette dun 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 dun 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
@ -21,8 +21,9 @@ Utile comme base pour un service (certificator, zapwall, etc.) qui consomme User
| Fichier | Rôle |
|---------|------|
| `index.html` | Page avec iframe UserWallet, zone de statut, bouton «Demander auth» |
| `src/main.ts` | Chargement iframe, écoute `message`, envoi `auth-request`, appel `verifyLoginProof`, mise à jour du statut |
| `index.html` | Page avec iframe UserWallet, zone de statut, lien « Description du contrat », bouton « Se connecter » |
| `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) |
| `package.json` | Scripts `dev` / `build` / `preview`, dépendance `service-login-verify` |
| `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 lURL configurée.
2. Lancer le skeleton (`npm run dev` ou servir `dist/`).
3. Ouvrir la page du skeleton : liframe affiche UserWallet.
4. **Demander auth** : cliquer «Demander auth (auth-request) » → envoi de `auth-request` à liframe.
5. **Login** : effectuer le flux de login dans liframe ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton appelle `verifyLoginProof`, puis affiche «Login accepté» ou «Login refusé : … ».
4. **Se connecter** : cliquer «Se connecter» → envoi du contrat à liframe, affichage de liframe.
5. **Login MFA** : effectuer le flux de login (MFA) dans liframe ; à 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
| Type | Sens | Rôle du skeleton |
|------|------|-------------------|
| `auth-request` | Parent → iframe | Envoyé au clic sur «Demander auth». |
| `auth-response` | Iframe → parent | Affichage «Auth reçu». |
| `contract` | Parent → iframe | Envoyé au clic sur «Se connecter» (et au load de liframe). |
| `login-proof` | Iframe → parent | Vérification via `verifyLoginProof`, mise à jour du statut. |
| `error` | Iframe → parent | Affichage du message derreur. |

View File

@ -1,6 +1,6 @@
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.
*/
export declare class PersistentNonceCache implements NonceCacheLike {

View File

@ -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.
*/
export class PersistentNonceCache {

View File

@ -116,7 +116,7 @@ export function GlobalActionBar(): JSX.Element {
<button
type="button"
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"
>
Afficher les mots de ma clé publique

View File

@ -1,99 +1,187 @@
import { useEffect } from 'react';
import { getStoredPairs } from '../utils/pairing';
import { getStoredRelays } from '../utils/relay';
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getStoredPairs, removePair } from '../utils/pairing';
import { publicKeyToBip32Words } from '../utils/bip32';
import { useIdentity } from '../hooks/useIdentity';
import { GraphResolver } from '../services/graphResolver';
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),
);
}
import type { PairConfig } from '../types/identity';
export function PairManagementScreen(): JSX.Element {
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(() => {
logPairsConfig();
if (identity !== null) {
void logContrats(identity).catch((err: unknown) => {
console.error('[UserWallet] Log contrats failed:', err);
const stored = getStoredPairs();
setPairs(stored);
}, []);
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>
);
}

View File

@ -19,16 +19,17 @@ export function PairingDisplayScreen(): JSX.Element {
const { connected: pairingConnected } = usePairingConnected();
const [words2nd, setWords2nd] = useState<string[]>([]);
const [wordInput, setWordInput] = useState<string[]>([]);
const [pubkey1stInput, setPubkey1stInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [justConnected, setJustConnected] = useState(false);
useEffect(() => {
const w = ensureLocalPairForSetup();
if (identity !== null && identity.publicKey !== undefined) {
const w = ensureLocalPairForSetup(identity.publicKey);
setWords2nd(w);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}
}, [identity]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isLoading || identity !== null) {
@ -51,21 +52,10 @@ export function PairingDisplayScreen(): JSX.Element {
const wordsText = wordInput.join(' ');
const parsed = parseAndValidatePairingWords(wordsText);
if (parsed === null) {
setError('Mots invalides. 8 mots requis.');
setError('Mots invalides. 17 mots requis.');
return;
}
const pubkeyHex = pubkey1stInput.trim();
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);
const pair = addRemotePairFromWords(parsed, []);
if (pair === null) {
setError('Mots invalides. Vérifiez la saisie.');
return;
@ -87,6 +77,8 @@ export function PairingDisplayScreen(): JSX.Element {
setError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
return;
}
// Use pair's publicKey if available (will be updated from signatures if not)
const remotePublicKey = remote.publicKey;
setIsConfirming(true);
try {
const ok = await runDevice2Confirmation({
@ -96,7 +88,7 @@ export function PairingDisplayScreen(): JSX.Element {
relays,
start: identity.t0_anniversaire,
end: Date.now(),
remotePublicKey: pubkeyHex,
remotePublicKey,
});
setJustConnected(ok);
} catch (err) {
@ -193,11 +185,11 @@ export function PairingDisplayScreen(): JSX.Element {
<main>
<h1>Saisir les mots du 1ʳ appareil</h1>
<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>
<form
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">
Mots du 1ʳ appareil
@ -206,18 +198,8 @@ export function PairingDisplayScreen(): JSX.Element {
value={wordInput}
onChange={setWordInput}
aria-describedby={error !== null ? 'pairing-display-err' : undefined}
aria-label="Saisir les 8 mots du 1er appareil"
/>
</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}
aria-label="Saisir les 17 mots du 1er appareil"
wordCount={17}
/>
</label>
{error !== null && (

View File

@ -25,15 +25,16 @@ export function PairingSetupBlock(): JSX.Element {
const [words, setWords] = useState<string[]>([]);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [remoteWordsInput, setRemoteWordsInput] = useState<string[]>([]);
const [remotePubkeyInput, setRemotePubkeyInput] = useState('');
const [remoteError, setRemoteError] = useState<string | null>(null);
const [hasCopiedToSecondDevice, setHasCopiedToSecondDevice] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
useEffect(() => {
const w = ensureLocalPairForSetup();
if (identity !== null && identity.publicKey !== undefined) {
const w = ensureLocalPairForSetup(identity.publicKey);
setWords(w);
}, []);
}
}, [identity]);
const url = useMemo(() => buildPairingDisplayUrl(), []);
@ -54,21 +55,10 @@ export function PairingSetupBlock(): JSX.Element {
const wordsText = remoteWordsInput.join(' ');
const parsed = parseAndValidatePairingWords(wordsText);
if (parsed === null) {
setRemoteError('Mots invalides. 8 mots requis.');
setRemoteError('Mots invalides. 17 mots requis.');
return;
}
const pubkeyHex = remotePubkeyInput.trim();
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);
const pair = addRemotePairFromWords(parsed, []);
if (pair === null) {
setRemoteError('Mots invalides. Vérifiez la saisie.');
return;
@ -83,7 +73,6 @@ export function PairingSetupBlock(): JSX.Element {
identity.privateKey === undefined
) {
setRemoteWordsInput([]);
setRemotePubkeyInput('');
navigate('/manage-pairs');
return;
}
@ -92,6 +81,8 @@ export function PairingSetupBlock(): JSX.Element {
setRemoteError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
return;
}
// Use pair's publicKey if available (will be updated from signatures if not)
const remotePublicKey = remote.publicKey;
setIsConfirming(true);
try {
await runDevice1Confirmation({
@ -99,7 +90,7 @@ export function PairingSetupBlock(): JSX.Element {
pairRemote: remote.uuid,
identity,
relays,
remotePublicKey: pubkeyHex,
remotePublicKey,
});
} catch (err) {
console.error('Pairing confirmation (device 1):', err);
@ -111,7 +102,6 @@ export function PairingSetupBlock(): JSX.Element {
}
setIsConfirming(false);
setRemoteWordsInput([]);
setRemotePubkeyInput('');
navigate('/manage-pairs');
};
@ -131,18 +121,6 @@ export function PairingSetupBlock(): JSX.Element {
>
{words.join(' ')}
</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 && (
<p>
<img
@ -173,7 +151,7 @@ export function PairingSetupBlock(): JSX.Element {
<h4 id="remote-words-heading">Mots du 2 appareil</h4>
<form
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">
Mots affichés par le 2 appareil
@ -182,18 +160,8 @@ export function PairingSetupBlock(): JSX.Element {
value={remoteWordsInput}
onChange={setRemoteWordsInput}
aria-describedby={remoteError !== null ? 'remote-words-err' : undefined}
aria-label="Saisir les 8 mots du 2e appareil"
/>
</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}
aria-label="Saisir les 17 mots du 2e appareil"
wordCount={17}
/>
</label>
{remoteError !== null && (

View File

@ -7,9 +7,10 @@ interface WordInputGridProps {
id?: string;
'aria-describedby'?: string;
'aria-label'?: string;
wordCount?: number;
}
const WORD_COUNT = 8;
const DEFAULT_WORD_COUNT = 17;
/**
* 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({
value,
@ -31,7 +33,9 @@ export function WordInputGrid({
id,
'aria-describedby': ariaDescribedBy,
'aria-label': ariaLabel,
wordCount = DEFAULT_WORD_COUNT,
}: WordInputGridProps): JSX.Element {
const WORD_COUNT = wordCount;
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState<number>(-1);
@ -209,12 +213,13 @@ export function WordInputGrid({
>
{index + 1}
</label>
<div style={{ position: 'relative' }}>
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
id={`${id ?? 'word'}-${index}`}
type="text"
type="password"
value={word}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
handleInputChange(index, e.target.value);
@ -236,6 +241,7 @@ export function WordInputGrid({
style={{
width: '100%',
padding: '0.5rem',
paddingRight: '2.5rem',
fontSize: '1rem',
fontFamily: 'monospace',
border: '1px solid var(--color-border, #ccc)',
@ -244,6 +250,34 @@ export function WordInputGrid({
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 && (
<div
ref={suggestionsRef}

View File

@ -2,6 +2,7 @@ import { hashStringAsync } from '../utils/canonical';
import { signMessage } from '../utils/crypto';
import { verifyMessageSignatures } from '../utils/verification';
import { idbGet, idbSet, idbRemove } from '../utils/indexedDbStorage';
import { updatePairPublicKey } from '../utils/pairing';
import {
getMessagesChiffres,
getSignatures,
@ -175,7 +176,7 @@ interface PublishPairingMessageParams {
relays: RelayConfig[];
message: MembreFinaliserMessage;
hash: string;
recipientPublicKey: string;
recipientPublicKey?: string;
senderIdentity: LocalIdentity;
}
@ -187,19 +188,19 @@ async function publishPairingMessage(
params: PublishPairingMessageParams,
): Promise<void> {
const { relays, message, hash, recipientPublicKey, senderIdentity } = params;
if (recipientPublicKey === undefined || recipientPublicKey === '') {
throw new Error(
'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).');
}
// ECDH encryption is optional: if recipientPublicKey is provided, use ECDH; otherwise publish without encryption
// The public key will be extracted from signatures and stored for future ECDH use
const enabled = relays.filter((r) => r.enabled);
if (enabled.length === 0) {
throw new Error('No enabled relays');
}
// If recipientPublicKey is provided, use ECDH encryption
if (recipientPublicKey !== undefined && recipientPublicKey !== '') {
const pk = senderIdentity.privateKey;
if (pk === undefined) {
throw new Error('Clé privée requise pour chiffrer le message de pairing (ECDH).');
}
const { encrypted, iv, senderPublicKey } = await encryptPairingMessage(
message,
recipientPublicKey,
@ -215,6 +216,20 @@ async function publishPairingMessage(
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);
}
}
}
interface PublishPairingMessageAndSignatureParams {
@ -222,7 +237,7 @@ interface PublishPairingMessageAndSignatureParams {
message: MembreFinaliserMessage;
hash: string;
sig: Signature;
recipientPublicKey: string;
recipientPublicKey?: string;
senderIdentity: LocalIdentity;
}
@ -485,7 +500,7 @@ interface RunDevice1ConfirmationParams {
pairRemote: string;
identity: LocalIdentity;
relays: RelayConfig[];
remotePublicKey: string;
remotePublicKey?: string;
}
/**
@ -496,10 +511,8 @@ export async function runDevice1Confirmation(
params: RunDevice1ConfirmationParams,
): Promise<boolean> {
const { pairLocal, pairRemote, identity, relays, remotePublicKey } = params;
if (remotePublicKey === undefined || remotePublicKey === '') {
throw new Error(
'Pairing DH obligatoire : clé publique du pair distant requise (exiger publicKey).',
);
if (remotePublicKey !== undefined && remotePublicKey !== '') {
// ECDH encryption enabled when remotePublicKey is provided
}
const { message, hash } = await createMembreFinaliserMessage(
pairLocal,
@ -524,12 +537,25 @@ export async function runDevice1Confirmation(
const msg = messageWithHash(message, hash);
const { valid } = verifyMessageSignatures(msg, sigs);
if (hasTwoDistinctSignatures(valid, identity.publicKey)) {
// Extract remote public key from signatures
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);
}
// Use extracted key or provided key for ECDH
const keyForEcdh = extractedRemotePublicKey ?? remotePublicKey;
if (keyForEcdh !== undefined && keyForEcdh !== '') {
await buildAndPublishPairingVersion2(
relays,
message,
remotePublicKey,
keyForEcdh,
identity,
);
}
await storePairingConfirmed(pairLocal, pairRemote, hash, 2);
return true;
}
@ -582,6 +608,16 @@ export async function runDevice2Confirmation(
if (valid.length === 0) {
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 sig = signMembreFinaliser(hash, identity, n);
await publishPairingSignature(relays, sig);

View File

@ -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.
*/

View File

@ -1,7 +1,8 @@
import {
generateUuid,
uuidToBip32Words,
bip32WordsToUuid,
publicKeyToBip32Words,
bip32WordsToPublicKey,
} from './bip32';
import type { PairConfig } from '../types/identity';
@ -32,18 +33,23 @@ export function storePairs(pairs: PairConfig[]): void {
/**
* 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;
words: string[];
} {
const uuid = generateUuid();
const words = uuidToBip32Words(uuid);
const words = publicKeyToBip32Words(identityPublicKey);
const pair: PairConfig = {
uuid,
membres_parents_uuid: membresParentsUuid,
is_local: true,
can_sign: true,
publicKey: identityPublicKey,
};
const pairs = getStoredPairs();
pairs.push(pair);
@ -53,25 +59,23 @@ export function createLocalPair(membresParentsUuid: string[]): {
/**
* 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(
words: string[],
membresParentsUuid: string[],
remotePublicKey?: string,
): PairConfig | null {
const uuid = bip32WordsToUuid(words);
if (uuid === null) {
const publicKey = bip32WordsToPublicKey(words);
if (publicKey === null) {
return null;
}
const uuid = generateUuid();
const pair: PairConfig = {
uuid,
membres_parents_uuid: membresParentsUuid,
is_local: false,
can_sign: false,
...(remotePublicKey !== undefined && remotePublicKey !== ''
? { publicKey: remotePublicKey }
: {}),
publicKey,
};
const pairs = getStoredPairs();
pairs.push(pair);
@ -89,7 +93,7 @@ export function generatePairingWords(): string[] {
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).
@ -103,8 +107,8 @@ export function parseAndValidatePairingWords(text: string): string[] | null {
if (words.length !== EXPECTED_PAIRING_WORD_COUNT) {
return null;
}
const uuid = bip32WordsToUuid(words);
return uuid !== null ? words : null;
const publicKey = bip32WordsToPublicKey(words);
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.
* 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 local = pairs.find((p) => p.is_local);
if (local !== undefined) {
return uuidToBip32Words(local.uuid);
if (local !== undefined && local.publicKey !== undefined) {
return publicKeyToBip32Words(local.publicKey);
}
const { words } = createLocalPair([]);
const { words } = createLocalPair([], identityPublicKey);
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.
*/
export function getLocalPairWords(): string[] | null {
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
if (local === undefined) {
if (local === undefined || local.publicKey === undefined) {
return null;
}
return uuidToBip32Words(local.uuid);
return publicKeyToBip32Words(local.publicKey);
}
/**
@ -165,3 +170,25 @@ export function getLocalPairWords(): string[] | null {
export function isPairingConnected(): boolean {
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);
}

View File

@ -1,6 +1,6 @@
# 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
@ -26,7 +26,7 @@ Ouvre par défaut sur `http://localhost:3024`. L'iframe pointe vers UserWallet (
## 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`).
- **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.
- **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/`).
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.
5. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l'iframe.
6. **Login** : depuis l'iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
5. **Se connecter** : cliquer « Se connecter » → envoi du contrat à l'iframe, affichage de l'iframe.
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
@ -165,8 +167,10 @@ Les raisons de refus possibles :
## Structure
- `index.html` : page avec iframe, zone de statut, bouton auth.
- `src/main.ts` : chargement de l'iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut, gestion des messages `contract`.
- `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 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/serviceContract.ts` : contrat de service skeleton avec UUID dédié, action login, et configuration de la clé publique via `VITE_SKELETON_SERVICE_PUBLIC_KEY`.
- `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`).

View File

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

View File

@ -45,7 +45,7 @@ if (existsSync(envPath)) {
// Write .env with public key
const envContent = `# Service wallet public key for website-skeleton
# 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}
`;
@ -55,7 +55,7 @@ writeFileSync(envPath, envContent, { mode: 0o600 });
const envPrivateContent = `# Service wallet private key for website-skeleton
# SECRET: Keep this file secure and never commit it to version control
# 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.
# Store it securely and never share it.

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
* {
box-sizing: border-box;
@ -19,28 +19,6 @@
font-size: 1.5rem;
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 {
margin: 1rem 0;
display: flex;
@ -83,18 +61,46 @@
border-color: #c82333;
}
#iframe-container {
margin: 1rem 0;
min-height: 400px;
border: 1px solid #ccc;
border-radius: 6px;
margin: 1.5rem 0;
border-radius: 12px;
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 {
width: 100%;
height: 600px;
height: 550px;
border: 0;
display: block;
background: #fafafa;
}
#connected-section {
padding: 1.5rem;
@ -117,9 +123,19 @@
h1 {
font-size: 1.25rem;
}
#iframe-container {
margin: 1rem 0;
border-radius: 8px;
}
#iframe-container iframe {
height: 500px;
}
.iframe-header {
padding: 0.6rem 0.8rem;
}
.iframe-header-subtitle {
display: none;
}
button {
width: 100%;
margin-bottom: 0.5rem;
@ -130,21 +146,25 @@
}
@media (max-width: 480px) {
#iframe-container iframe {
height: 400px;
height: 450px;
}
}
</style>
</head>
<body>
<h1>Website skeleton intégration iframe UserWallet</h1>
<p id="status">En attente du login depuis l'iframe.</p>
<h1>4NK un nouveau web - site d'exemple</h1>
<div id="login-section">
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
<div class="button-group">
<button type="button" id="btn-login" class="primary">Se connecter</button>
<button type="button" id="btn-auth">Demander auth (auth-request)</button>
</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>
</div>
</div>
@ -152,6 +172,7 @@
<div id="connected-section" style="display: none;">
<h2>Vous êtes connecté</h2>
<div id="user-info"></div>
<p><a href="contrat.html">Le contrat</a> · <a href="membre.html">Qui êtes-vous ?</a></p>
<div class="button-group">
<button type="button" id="btn-logout" class="danger">Se déconnecter</button>
</div>

View 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>

View File

@ -14,8 +14,6 @@ import { getSkeletonServiceContractData } from './serviceContract.js';
import type { Contrat, Action } from './config.js';
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 btnLogout = document.getElementById('btn-logout') as HTMLButtonElement;
const iframeContainer = document.getElementById('iframe-container') as HTMLDivElement;
@ -70,13 +68,6 @@ function isLoggedIn(): boolean {
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 {
if (loginSection !== null) {
loginSection.style.display = 'block';
@ -85,7 +76,7 @@ function showLoginInterface(): void {
connectedSection.style.display = 'none';
}
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 {
if (iframe?.contentWindow == null) {
setStatus('Iframe non prêt.', 'rejected');
return;
}
// Send contract if available
sendContractToIframe();
// Show iframe
if (iframeContainer !== null) {
iframeContainer.style.display = 'block';
}
setStatus('Connexion en cours...', 'pending');
}
function handleLogout(): void {
clearSession();
updateUI();
setStatus('Déconnecté.', 'pending');
}
/**
@ -181,21 +155,12 @@ function updateValidatorsFromContract(
if (validateurs !== null) {
currentValidateurs = 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 {
const d = event.data;
function handleMessage(msg: MessageEvent): void {
const d = msg.data;
if (d?.type === undefined) {
return;
}
@ -224,8 +189,6 @@ function handleMessage(event: MessageEvent): void {
}
}
updateValidatorsFromContract(payload.contrat, contratsFils, actions);
} else {
setStatus('Message contrat reçu mais structure invalide.', 'rejected');
}
return;
}
@ -239,37 +202,25 @@ function handleMessage(event: MessageEvent): void {
});
if (result.accept) {
setSession(proof);
setStatus('Login accepté. Session ouverte.', 'accepted');
updateUI();
} else {
setStatus(`Login refusé: ${result.reason ?? 'inconnu'}`, 'rejected');
}
return;
}
if (d.type === 'auth-response') {
setStatus('Auth reçu (auth-response).', 'pending');
return;
}
if (d.type === 'error') {
const msg = (d.payload as { message?: string })?.message ?? 'Erreur';
setStatus(`Erreur iframe: ${msg}`, 'rejected');
return;
}
}
function init(): void {
if (iframe == null || statusEl === null) {
if (iframe == null) {
return;
}
iframe.src = USERWALLET_ORIGIN;
btnAuth?.addEventListener('click', sendAuthRequest);
btnLogin?.addEventListener('click', handleLogin);
btnLogout?.addEventListener('click', handleLogout);
window.addEventListener('message', handleMessage);
// Initialize validators from skeleton service contract
const validateurs = extractLoginValidators(
storedContract ?? skeletonContractData.contrat,
storedContratsFils,
@ -280,28 +231,11 @@ function init(): void {
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
}
// Send contract to iframe when it's loaded
iframe.addEventListener('load', () => {
sendContractToIframe();
});
// Check if already logged in
if (isLoggedIn()) {
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();

View File

@ -1,25 +1,39 @@
import type { Contrat, Action } from './config.js';
/**
* Service UUID for website-skeleton.
* This is a real service contract for the skeleton service.
* Service UUID for website-skeleton (UUID v4).
*/
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.
@ -44,7 +58,7 @@ function getSkeletonServiceContract(): Contrat {
datajson: {
types_names_chiffres: 'contrat',
services_uuid: [SKELETON_SERVICE_UUID],
types_uuid: ['skeleton-contract-type-uuid'],
types_uuid: [SKELETON_CONTRACT_TYPE_UUID],
label: 'Contrat de service Website Skeleton',
},
};
@ -73,7 +87,7 @@ function getSkeletonLoginAction(): Action {
datajson: {
types_names_chiffres: 'action,login',
services_uuid: [SKELETON_SERVICE_UUID],
types_uuid: ['skeleton-action-type-uuid'],
types_uuid: [SKELETON_ACTION_TYPE_UUID],
label: 'Action login Website Skeleton',
},
};

View File

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

View File

@ -1,5 +1,5 @@
[Unit]
Description=Website skeleton (UserWallet iframe integration)
Description=4NK un nouveau web - site d'exemple
After=network.target
[Service]