UserWallet: Multiple feature implementations and updates

**Motivations:**
- Implement BIP39 mnemonic import for identity creation
- Add password-based key protection for enhanced security
- Improve pairing workflow with QR code and URL display
- Migrate hash cache from LocalStorage to IndexedDB for better scalability
- Update signet-dashboard and mempool components

**Root causes:**
- N/A (feature implementations)

**Correctifs:**
- N/A (no bug fixes in this commit)

**Evolutions:**
- BIP39 mnemonic import: Support for 12/24 word English mnemonics with BIP32 derivation path m/44'/0'/0'/0/0
- Key protection: Password-based encryption of private keys at rest with unlock/lock functionality
- Pairing workflow: QR code and URL display for device pairing, form-based word exchange between devices
- IndexedDB migration: Hash cache moved from LocalStorage to IndexedDB to avoid size limitations
- Global action bar: URL parameter support for navigation
- Pairing connection: Enhanced pairing status management

**Pages affectées:**
- userwallet/src/utils/identity.ts
- userwallet/src/utils/keyProtection.ts
- userwallet/src/utils/sessionUnlockedKey.ts
- userwallet/src/utils/indexedDbStorage.ts
- userwallet/src/utils/cache.ts
- userwallet/src/utils/pairing.ts
- userwallet/src/components/UnlockScreen.tsx
- userwallet/src/components/PairingDisplayScreen.tsx
- userwallet/src/components/PairingSetupBlock.tsx
- userwallet/src/components/GlobalActionBar.tsx
- userwallet/src/components/HomeScreen.tsx
- userwallet/src/components/ImportIdentityScreen.tsx
- userwallet/src/components/DataExportImportScreen.tsx
- userwallet/src/hooks/useIdentity.ts
- userwallet/src/hooks/usePairingConnected.ts
- userwallet/src/services/syncService.ts
- userwallet/src/services/pairingConfirm.ts
- userwallet/src/App.tsx
- userwallet/package.json
- userwallet/docs/specs.md
- userwallet/docs/storage.md
- userwallet/docs/synthese.md
- signet-dashboard/public/*.html
- signet-dashboard/public/app.js
- signet-dashboard/public/styles.css
- mempool (submodule updates)
- hash_list.txt, hash_list_cache.txt, utxo_list.txt, utxo_list_cache.txt, fees_list.txt
- features/*.md (documentation files)
This commit is contained in:
ncantu 2026-01-26 10:23:34 +01:00
parent 08eb90ed6b
commit 5689693507
54 changed files with 64178 additions and 37888 deletions

View File

@ -0,0 +1,52 @@
# UserWallet Paramètres URL pour contrôler l'affichage des actions globales
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
Permettre de contrôler l'affichage des boutons "Supprimer" et "Afficher les mots de ma clé publique" via des paramètres d'URL pour adapter l'interface selon le contexte d'utilisation (usage normal vs iframe).
## Impacts
- **Fonctionnels** : Les boutons "Supprimer" et "Afficher les mots de ma clé publique" sont masqués par défaut dans un contexte iframe, mais peuvent être affichés via les paramètres d'URL `?showDelete=true` et `?showWords=true`. En usage normal (hors iframe), les boutons sont affichés par défaut.
- **Techniques** : Détection automatique du contexte iframe et lecture des paramètres d'URL pour forcer l'affichage même en iframe.
## Modifications
### GlobalActionBar.tsx
- Import de `useSearchParams` depuis `react-router-dom`
- Détection du contexte iframe via `window.self !== window.top`
- Lecture des paramètres d'URL `showDelete` et `showWords`
- Logique d'affichage conditionnelle :
- Par défaut : affichage des boutons (usage normal)
- En iframe : masquage par défaut
- Paramètres d'URL : forcer l'affichage même en iframe avec `?showDelete=true` et/ou `?showWords=true`
## Flux utilisateur
1. **Usage normal (hors iframe)** : Les boutons "Supprimer" et "Afficher les mots de ma clé publique" sont affichés par défaut.
2. **Usage iframe (par défaut)** : Les boutons sont masqués pour une interface épurée.
3. **Usage iframe avec paramètres** : Ajouter `?showDelete=true` et/ou `?showWords=true` dans l'URL pour forcer l'affichage des boutons correspondants.
## Exemples d'utilisation
- **Iframe sans boutons** : `https://test.userwallet.4nkweb.com/` (boutons masqués)
- **Iframe avec bouton "Supprimer"** : `https://test.userwallet.4nkweb.com/?showDelete=true`
- **Iframe avec bouton "Mots"** : `https://test.userwallet.4nkweb.com/?showWords=true`
- **Iframe avec les deux boutons** : `https://test.userwallet.4nkweb.com/?showDelete=true&showWords=true`
## Modalités de déploiement
- Rebuild de l'application userwallet
- Déploiement des assets sur les environnements concernés
## Modalités d'analyse
- Vérifier l'affichage par défaut des boutons en usage normal (hors iframe)
- Vérifier le masquage des boutons en iframe sans paramètres
- Vérifier l'affichage forcé des boutons en iframe avec les paramètres d'URL appropriés
- Tester les différentes combinaisons de paramètres (`showDelete`, `showWords`)
- Vérifier que la détection d'iframe fonctionne correctement
- Accessibilité (ARIA, contraste, clavier) maintenue pour tous les cas d'usage

View File

@ -0,0 +1,46 @@
# UserWallet Import BIP39 mnemonic
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
Permettre limport dune identité à partir dune phrase BIP39 (12 ou 24 mots anglais) en plus de la clé privée hex brute, en dérivant la clé via BIP32.
## Impacts
- **Fonctionnels** : Lécran « Importer une identité » accepte soit 64 caractères hex (clé brute), soit une phrase BIP39 valide (12 ou 24 mots, anglais). La clé est dérivée avec le chemin m/44'/0'/0'/0/0.
- **Techniques** : Dépendances `@scure/bip39` et `@scure/bip32`. Nouvelle fonction `privateKeyFromMnemonic` dans `identity.ts`, extension de `importIdentity`.
## Modifications
### identity.ts
- Import de `validateMnemonic`, `mnemonicToSeedSync` (@scure/bip39), wordlist anglaise, `HDKey` (@scure/bip32), `bytesToHex` (@noble/hashes).
- `privateKeyFromMnemonic(mnemonic)` : `mnemonicToSeedSync``HDKey.fromMasterSeed``derive("m/44'/0'/0'/0/0")``bytesToHex(privateKey)`.
- `importIdentity` : si lentrée est 64 hex → comportement actuel (clé brute). Sinon, normalisation (espaces, lowercase), vérification 12/24 mots et `validateMnemonic`, puis `privateKeyFromMnemonic`. Création de lidentité comme avant.
### ImportIdentityScreen
- Placeholder et label mis à jour (BIP39, 12/24 mots).
- Hint : « Clé hex brute ou phrase BIP39 (anglais). Passphrase BIP39 non supportée. Dérivation m/44'/0'/0'/0/0. » + `aria-describedby` / `id` pour laccessibilité.
### Documentation
- `userwallet/docs/synthese.md` : identité import BIP39 et chemin de dérivation indiqués ; passphrase non supportée.
## Limitations
- **Passphrase BIP39** : non supportée (toujours `''`).
- **Wordlist** : anglais uniquement.
- **Chemin** : fixe m/44'/0'/0'/0/0 (pas de paramétrage).
## Modalités de déploiement
- `npm install` (nouvelles deps), rebuild frontend, déploiement des assets.
## Modalités danalyse
- Importer une phrase BIP39 valide (12 ou 24 mots) et vérifier que lidentité est créée et que la clé publique correspond à une dérivation m/44'/0'/0'/0/0.
- Importer une clé hex brute : vérifier quaucune régression.
- Entrée invalide (mots incorrects, mauvais nombre) : message derreur, pas didentité créée.

View File

@ -0,0 +1,55 @@
# UserWallet IndexedDB pour hash_cache
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
Stocker le cache des hash vus (`userwallet_hash_cache`) dans IndexedDB au lieu de LocalStorage, pour éviter les limites de taille et permettre un volume plus important sans dégradation.
## Impacts
- **Fonctionnels** : Comportement identique (déduplication des messages sync). Pruning à 10k entrées conservé.
- **Techniques** : Nouveau module `indexedDbStorage` (idbGet, idbSet, idbRemove). `HashCache` utilise IndexedDB, `init()` async, `markSeenBatch` async. `SyncService` init avant sync, collecte des nouveaux hashes puis un seul `markSeenBatch`. Export/import lisent/écrivent `hash_cache` via IndexedDB (export/import async).
## Modifications
### utils/indexedDbStorage.ts (nouveau)
- DB `userwallet`, version 1, store `kv` (keyPath `key`).
- `idbGet(key)`, `idbSet(key, value)`, `idbRemove(key)`. Toutes async.
### utils/cache.ts
- `HashCache` : plus de chargement dans le constructeur. `async init()` charge depuis IndexedDB (et migre depuis localStorage si présent).
- `markSeen` : en mémoire uniquement. `markSeenBatch(hashes)` : ajoute + persiste via IndexedDB.
- `clear()` async, utilise `idbRemove`.
- Sauvegarde avec pruning à 10k entrées.
### services/syncService.ts
- `async init()` : délègue à `hashCache.init()`.
- Dans `sync()` : collecte des `newHashes`, puis `await hashCache.markSeenBatch(newHashes)` en fin de sync.
### Composants
- **SyncScreen**, **ServiceListScreen** : `await syncService.init()` avant `sync()`.
### exportImport
- `exportUserWalletData` async : lit `hash_cache` via `idbGet`.
- `importUserWalletData` async : écrit `hash_cache` via `idbSet`.
- **DataExportImportScreen** : `handleExport` async, `handleImportFile` onload async.
### Migration
- Au premier `HashCache.init()`, si IndexedDB na pas `userwallet_hash_cache` et que localStorage la, copie vers IndexedDB puis suppression du localStorage.
## Modalités de déploiement
- Rebuild frontend, déploiement des assets. Migration automatique au premier sync.
## Modalités danalyse
- Effectuer une sync : vérifier quaucune régression. Exporter puis réimporter : vérifier que `hash_cache` est bien présent après import.
- Vérifier en DevTools (Application → IndexedDB → userwallet) la présence du store `kv` et de la clé `userwallet_hash_cache`.

View File

@ -0,0 +1,60 @@
# UserWallet Protection des clés par mot de passe
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
Chiffrer la clé privée au repos avec un mot de passe utilisateur. Déverrouillage requis pour signer (login, iframe auth). Verrouillage possible pour vider la session sans toucher au stockage.
## Impacts
- **Fonctionnels** : Activation/désactivation de la protection, déverrouillage au démarrage si protégé, verrouillage manuel. Export inclut la clé seulement si déverrouillé.
- **Techniques** : `keyProtection` (PBKDF2 + AES-GCM), `sessionUnlockedKey`, extension de `identity` (enable/disable/unlock/lock). `LocalIdentity.privateKey` optionnel. Gate applicatif (UnlockScreen) quand protégé et non déverrouillé.
## Modifications
### Nouveaux fichiers
- **`utils/keyProtection.ts`** : `encryptPrivateKey`, `decryptPrivateKey`, `EncryptedPayload`. PBKDF2 100k itérations, AES-GCM, salt/iv aléatoires.
- **`utils/sessionUnlockedKey.ts`** : `getUnlockedPrivateKey`, `setUnlockedPrivateKey` (module-level).
- **`components/UnlockScreen.tsx`** : Formulaire mot de passe, appel à `unlock`, affichage erreur.
### identity.ts
- `isProtectionEnabled`, `enableProtection`, `disableProtection`, `unlock`, `lock`.
- Stockage : `userwallet_identity_encrypted` (ciphertext, iv, salt). Identité stockée sans `privateKey` quand protection activée.
### useIdentity
- `isProtected`, `isUnlocked`, `enableProtection`, `disableProtection`, `unlock`, `lock`, `refreshIdentity`.
- Identité renvoyée : stockée + `privateKey` de session si protégé et déverrouillé.
### App
- **AppGate** : Si `identity && isProtected && !isUnlocked``UnlockScreen`, sinon `AppContent`.
### UI
- **HomeScreen** : Bouton « Verrouiller » si protégé et déverrouillé.
- **DataExportImportScreen** : Section « Protection par mot de passe » (activer / désactiver / verrouiller). Gestion erreurs export si verrouillé.
### Autres
- **useChannel** : Si `identity` sans `privateKey` → erreur « Identité verrouillée ».
- **LoginScreen** : Vérification `identity.privateKey` avant signature.
- **exportImport** : Export utilise la clé de session si protégé ; sinon throw « Déverrouillez… ».
### Types
- **`LocalIdentity.privateKey`** : optionnel.
## Modalités de déploiement
- Rebuild frontend, déploiement des assets. Aucune migration.
## Modalités danalyse
- Activer la protection → recharger → UnlockScreen. Déverrouiller → accès normal. Verrouiller → UnlockScreen à nouveau.
- Exporter sans déverrouiller (si contournement du gate) → erreur. Exporter après déverrouillage → JSON contient `identity.privateKey`.
- Désactiver la protection → identité de nouveau en clair ; `userwallet_identity_encrypted` supprimé.

View File

@ -0,0 +1,58 @@
# UserWallet QR et URL pairing quand statut non satisfait
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
Lorsque le statut pairing est **Satisfait : Non**, afficher un bloc «Configurer le pairing avec un 2ᵉ appareil» avec:
- mots du 1ᵉʳ appareil (représentation de la clé publique, 8 mots, format type BIP32), QR et URL ;
- **formulaire de saisie** des mots du 2ᵉ appareil sur le 1ᵉʳ.
Sur le **2ᵉ appareil** (scan du QR ou ouverture de lURL) : **page de saisie** des mots du 1ᵉʳ appareil (pré-remplie si `?words=` présent).
## Impacts
- **Fonctionnels** : Bloc pairing avec formulaire «Mots du 2ᵉ appareil» sur le 1ᵉʳ ; page `/pairing-display` (avec ou sans `?words=`) = formulaire «Saisir les mots du 1ᵉʳ appareil», pré-rempli depuis lURL si valide. Soumission → `addRemotePairFromWords`, puis succès + liens.
- **Techniques** : `parseAndValidatePairingWords` et bypass `/pairing-display` pour tout path (pas seulement si `?words=`).
## Modifications
### pairing.ts
- `generatePairingWords()` : inchangé.
- `parseAndValidatePairingWords(text)` : parse (whitespace), valide 8 mots + `bip32WordsToUuid`, retourne `string[] | null`.
### Composants
- **PairingSetupBlock** : mots du 1ᵉʳ + QR + URL. **Section «Mots du 2ᵉ appareil»** : formulaire (textarea, «Associer le pair»). Soumission → `parseAndValidatePairingWords``addRemotePairFromWords``navigate('/manage-pairs')`. Erreurs en state, pas `alert`.
- **PairingDisplayScreen** : **formulaire** «Saisir les mots du 1ᵉʳ appareil». Pré-rempli depuis `?words=` si valide. Soumission → `addRemotePairFromWords` → message succès + liens Accueil / Gérer les pairs.
### App.tsx
- `usePairingDisplayBypass()` : `pathname === /pairing-display` (avec ou sans `?words=`).
### HomeScreen
- Si `!pairingSatisfied` : afficher `PairingSetupBlock` (et masquer identité / pairing / relais).
### package.json (userwallet)
- `qrcode`, `@types/qrcode`.
## Flux utilisateur
1. **Appareil 1** : Accueil, pairing non satisfait → bloc avec mots du 1ᵉʳ, QR, URL, et **saisie des mots du 2ᵉ**.
2. **Appareil 2** : Scanner le QR (ou ouvrir lURL) → page **saisie des mots du 1ᵉʳ** (pré-remplie si `?words=`). Soumettre → pair ajouté.
3. **Appareil 1** : Saisir les mots du 2ᵉ, «Associer le pair» → redirection vers «Gérer les pairs».
## Modalités de déploiement
- `npm install` dans userwallet (nouvelles deps), rebuild, déploiement des assets.
## Modalités danalyse
- Accueil avec pairing non satisfait : bloc avec mots du 1ᵉʳ, QR, URL, formulaire «Mots du 2ᵉ appareil».
- Ouvrir lURL (ou `/pairing-display`) sur un 2ᵉ appareil : formulaire «Saisir les mots du 1ᵉʳ» ; si `?words=` valide, pré-rempli.
- Soumission des formulaires : validation 8 mots, `addRemotePairFromWords`, messages derreur ou succès.
- Accessibilité (ARIA, contraste, clavier) des formulaires et des liens.

2667
fees_list.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
2026-01-25T23:53:35.210Z;9245;000000057a9ba10877ec7e33c55ab124354dd4cd693992d115068dabdf868264
2026-01-26T07:56:10.377Z;9780;0000000cdf43bdbf9349b94f71389cc9d423557a67822f98ab790db8637e95f6

View File

@ -83,8 +83,9 @@
}
.param-table th {
background: #f5f5f5;
background: var(--card-background);
font-weight: bold;
color: var(--text-color);
}
.param-name {
@ -99,7 +100,7 @@
}
.param-optional {
color: #666;
color: var(--text-color);
}
.code-block {
@ -130,27 +131,30 @@
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
background: rgba(13, 202, 240, 0.15);
border-left: 4px solid #0dcaf0;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
color: var(--text-color);
}
.warning-box {
background: #fff3cd;
background: rgba(255, 193, 7, 0.15);
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
color: var(--text-color);
}
.error-box {
background: #f8d7da;
background: rgba(220, 53, 69, 0.2);
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
color: #ff6b6b;
}
.auth-section {
@ -193,7 +197,7 @@
.status-400 {
background: #ffc107;
color: #000;
color: var(--background-color);
}
.status-401 {

View File

@ -540,7 +540,7 @@ function handleFileSelect(event) {
`;
if (isOverLimit) {
infoHtml += `<br><span style="color: #d32f2f; font-weight: bold;">⚠️ Fichier trop volumineux (limite : 100 MB)</span>`;
infoHtml += `<br><span style="color: #ff6b6b; font-weight: bold;">⚠️ Fichier trop volumineux (limite : 100 MB)</span>`;
}
fileInfo.innerHTML = infoHtml;
@ -896,10 +896,10 @@ async function anchorWithWatermark(apiKey) {
if (data.success) {
const mempoolBaseUrl = 'https://mempool.4nkweb.com/fr';
const originalTxidLink = data.original.txid
? `<a href="${mempoolBaseUrl}/tx/${data.original.txid}" target="_blank" style="color: #0066cc; text-decoration: underline;">${data.original.txid}</a>`
? `<a href="${mempoolBaseUrl}/tx/${data.original.txid}" target="_blank" style="color: #6ec6ff; text-decoration: underline;">${data.original.txid}</a>`
: 'N/A';
const watermarkedTxidLink = data.watermarked.txid
? `<a href="${mempoolBaseUrl}/tx/${data.watermarked.txid}" target="_blank" style="color: #0066cc; text-decoration: underline;">${data.watermarked.txid}</a>`
? `<a href="${mempoolBaseUrl}/tx/${data.watermarked.txid}" target="_blank" style="color: #6ec6ff; text-decoration: underline;">${data.watermarked.txid}</a>`
: 'N/A';
let resultHtml = `
@ -932,7 +932,7 @@ async function anchorWithWatermark(apiKey) {
`;
}
resultHtml += `<p style="color: #4CAF50; font-weight: bold;">📥 Téléchargement automatique en cours...</p>`;
resultHtml += `<p style="color: #90ee90; font-weight: bold;">📥 Téléchargement automatique en cours...</p>`;
showResult('anchor-result', 'success', resultHtml);

View File

@ -20,17 +20,18 @@
.header-section .back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
color: #6ec6ff;
text-decoration: none;
}
.header-section .back-link:hover {
text-decoration: underline;
}
.info-section {
background: #f8f9fa;
background: var(--card-background);
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border: 1px solid var(--border-color);
}
.info-section p {
margin: 5px 0;
@ -41,23 +42,24 @@
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
background: var(--card-background);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--border-color);
}
th {
background: #007bff;
color: white;
background: var(--card-background);
color: var(--text-color);
font-weight: bold;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f5f5;
background: rgba(255, 255, 255, 0.05);
}
.hash-cell {
font-family: monospace;
@ -69,7 +71,7 @@
font-size: 0.9em;
}
.txid-link {
color: #007bff;
color: #6ec6ff;
text-decoration: none;
}
.txid-link:hover {
@ -79,14 +81,15 @@
text-align: center;
padding: 40px;
font-size: 1.2em;
color: #666;
color: var(--text-color);
}
.error {
background: #f8d7da;
color: #721c24;
background: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
border: 1px solid #dc3545;
}
.refresh-button {
background: #28a745;
@ -108,17 +111,20 @@
.search-section {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
background: var(--card-background);
border-radius: 5px;
border: 1px solid var(--border-color);
}
.search-input {
width: 100%;
max-width: 500px;
padding: 10px;
font-size: 1em;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 5px;
font-family: monospace;
background-color: var(--card-background);
color: var(--text-color);
}
.pagination {
display: flex;
@ -146,7 +152,7 @@
}
.pagination-info {
padding: 0 15px;
color: #666;
color: var(--text-color);
}
.confirmed-check {
text-align: center;
@ -170,7 +176,7 @@
<p><strong>Total de hash ancrés :</strong> <span id="hash-count">-</span></p>
<p><strong>Dernière mise à jour :</strong> <span id="last-update">-</span></p>
<button class="refresh-button" onclick="loadHashList()">Actualiser</button>
<a href="/api/hash/list.txt" download="hash_list.txt" style="margin-left: 10px; color: #007bff; text-decoration: none;">📥 Télécharger le fichier texte</a>
<a href="/api/hash/list.txt" download="hash_list.txt" style="margin-left: 10px; color: #6ec6ff; text-decoration: none;">📥 Télécharger le fichier texte</a>
</div>
</div>

View File

@ -125,7 +125,7 @@
<div id="file-tab" class="tab-content">
<label for="anchor-file">Fichier à ancrer :</label>
<input type="file" id="anchor-file" onchange="handleFileSelect(event)">
<p class="file-limit-info" style="font-size: 0.9em; color: #666; margin-top: 5px; margin-bottom: 10px;">
<p class="file-limit-info" style="font-size: 0.9em; color: #999; margin-top: 5px; margin-bottom: 10px;">
<strong>Limite de taille :</strong> 100 MB maximum
</p>
<div id="file-info" class="file-info"></div>

View File

@ -14,8 +14,9 @@
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
margin-bottom: 30px;
border: 1px solid var(--border-color);
}
.config-section h2 {
@ -61,9 +62,10 @@
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
margin-bottom: 30px;
text-align: center;
border: 1px solid var(--border-color);
}
.payment-section h2 {
@ -85,12 +87,14 @@
.payment-address {
font-family: 'Courier New', monospace;
background: #f5f5f5;
background: var(--card-background);
color: var(--text-color);
padding: 15px;
border-radius: 5px;
margin: 15px 0;
word-break: break-all;
font-size: 0.9em;
border: 1px solid var(--border-color);
}
.nostr-profile-link {
@ -112,8 +116,9 @@
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
margin-bottom: 30px;
border: 1px solid var(--border-color);
}
.wallet-section h2 {
@ -127,8 +132,9 @@
align-items: center;
margin: 20px 0;
padding: 15px;
background: #f5f5f5;
background: var(--card-background);
border-radius: 5px;
border: 1px solid var(--border-color);
}
.wallet-checkbox input[type="checkbox"] {
@ -144,11 +150,12 @@
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
background: rgba(13, 202, 240, 0.15);
border-left: 4px solid #0dcaf0;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
color: var(--text-color);
}
.info-box p {
@ -156,12 +163,12 @@
}
.success-message {
background: #d4edda;
background: rgba(40, 167, 69, 0.2);
border-left: 4px solid var(--success-color);
padding: 15px;
margin: 20px 0;
border-radius: 5px;
color: #155724;
color: #90ee90;
display: none;
}
@ -176,6 +183,16 @@
.back-link:hover {
text-decoration: underline;
}
code {
background: var(--card-background);
color: var(--primary-color);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
border: 1px solid var(--border-color);
}
</style>
</head>
<body>

View File

@ -19,7 +19,7 @@
background: linear-gradient(135deg, #f7931a, #ff6b35);
color: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}
.learn-header h1 {
@ -47,7 +47,8 @@
background: var(--card-background);
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color);
}
.concept-section h2 {
@ -132,28 +133,31 @@
border-radius: 5px;
padding: 15px;
margin: 10px 0;
background: white;
background: var(--card-background);
}
.tx-input {
background: #e3f2fd;
border-left: 3px solid #2196f3;
background: rgba(13, 202, 240, 0.15);
border-left: 3px solid #0dcaf0;
padding: 10px;
margin: 5px 0;
color: var(--text-color);
}
.tx-output {
background: #e8f5e9;
border-left: 3px solid #4caf50;
background: rgba(40, 167, 69, 0.15);
border-left: 3px solid #28a745;
padding: 10px;
margin: 5px 0;
color: var(--text-color);
}
.tx-opreturn {
background: #fff3e0;
border-left: 3px solid #ff9800;
background: rgba(255, 193, 7, 0.15);
border-left: 3px solid #ffc107;
padding: 10px;
margin: 5px 0;
color: var(--text-color);
}
.interactive-button {
@ -174,26 +178,28 @@
}
.interactive-button:disabled {
background-color: #ccc;
background-color: #555;
color: #888;
cursor: not-allowed;
}
.highlight {
background: #fff3cd;
background: rgba(255, 193, 7, 0.2);
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
}
.code-snippet {
background: #f5f5f5;
border: 1px solid #ddd;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 5px;
padding: 15px;
margin: 15px 0;
font-family: 'Courier New', monospace;
font-size: 0.9em;
overflow-x: auto;
color: var(--text-color);
}
.flow-diagram {
@ -235,7 +241,17 @@
.utxo-item.spent {
opacity: 0.6;
border-left-color: #999;
border-left-color: var(--border-color);
}
code {
background: var(--card-background);
color: var(--primary-color);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
border: 1px solid var(--border-color);
}
.utxo-item.locked {
@ -306,16 +322,16 @@
<div class="visual-diagram">
<div class="block-visual">
<div class="block-header">Bloc #<span id="example-block-height">-</span></div>
<div style="font-size: 0.9em; color: #666; margin-bottom: 15px;">
<div style="font-size: 0.9em; color: var(--text-color); margin-bottom: 15px;">
Hash: <span id="example-block-hash" class="example-data">Chargement...</span>
</div>
<div style="font-size: 0.9em; color: #666; margin-bottom: 15px;">
<div style="font-size: 0.9em; color: var(--text-color); margin-bottom: 15px;">
Timestamp: <span id="example-block-time">-</span>
</div>
<div style="font-size: 0.9em; color: #666; margin-bottom: 15px;">
<div style="font-size: 0.9em; color: var(--text-color); margin-bottom: 15px;">
Transactions: <span id="example-block-tx-count">-</span>
</div>
<div style="font-size: 0.9em; color: #666;">
<div style="font-size: 0.9em; color: var(--text-color);">
Taille: <span id="example-block-size">-</span> bytes
</div>
</div>
@ -494,7 +510,7 @@ Frais payés: 1 200 sats
Total: <span id="example-fee-outputs">-</span> sats
</div>
<div style="text-align: center; margin: 10px 0; font-size: 1.5em;">=</div>
<div style="background: #fff3cd; border-left: 3px solid #ff9800; padding: 15px; margin: 10px 0; border-radius: 5px;">
<div style="background: rgba(255, 193, 7, 0.15); border-left: 3px solid #ffc107; padding: 15px; margin: 10px 0; border-radius: 5px;">
<strong>💰 Frais</strong><br>
<span id="example-fee-amount" style="font-size: 1.2em; color: var(--primary-color);">-</span> sats
</div>
@ -542,7 +558,7 @@ Change renvoyé: 38 800 sats
<strong>📤 Sortie 2 - Change</strong><br>
38 800 sats → tb1q... (votre adresse)
</div>
<div style="background: #fff3cd; border-left: 3px solid #ff9800; padding: 10px; margin: 10px 0; border-radius: 5px;">
<div style="background: rgba(255, 193, 7, 0.15); border-left: 3px solid #ffc107; padding: 10px; margin: 10px 0; border-radius: 5px;">
<strong>💰 Frais</strong><br>
1 200 sats → Mineur
</div>
@ -583,7 +599,7 @@ Change renvoyé: 38 800 sats
<div class="block-visual">
<div class="block-header">Bloc Miné</div>
<div class="transaction-box">
<div class="tx-input" style="background: #fff3cd; border-left-color: #ff9800;">
<div class="tx-input" style="background: rgba(255, 193, 7, 0.15); border-left-color: #ffc107;">
<strong>🎁 Coinbase (Aucune entrée)</strong><br>
Crée de nouveaux bitcoins
</div>
@ -730,7 +746,7 @@ Change renvoyé: 38 800 sats
<div class="tx-opreturn">
<strong>📝 OP_RETURN</strong><br>
Contient: "ANCHOR:" + hash (64 caractères hex)<br>
<span style="font-size: 0.9em; color: #666;">Cet output n'est pas dépensable, il sert uniquement à stocker le hash</span>
<span style="font-size: 0.9em; color: var(--text-color);">Cet output n'est pas dépensable, il sert uniquement à stocker le hash</span>
</div>
<div class="tx-output">
<strong>📤 Output d'Ancrage</strong><br>
@ -744,7 +760,7 @@ Change renvoyé: 38 800 sats
<strong>📤 Change (si nécessaire)</strong><br>
Monnaie de retour
</div>
<div style="background: #fff3cd; border-left: 3px solid #ff9800; padding: 10px; margin: 10px 0; border-radius: 5px;">
<div style="background: rgba(255, 193, 7, 0.15); border-left: 3px solid #ffc107; padding: 10px; margin: 10px 0; border-radius: 5px;">
<strong>💰 Frais</strong><br>
1200 sats → Mineur
</div>
@ -772,7 +788,7 @@ OP_RETURN "ANCHOR:abc123def456..."
</section>
<footer style="text-align: center; padding: 40px 20px; margin-top: 60px; background: var(--card-background); border-radius: 10px;">
<p style="color: #666; margin-bottom: 10px;">Bitcoin Ancrage Dashboard - Équipe 4NK</p>
<p style="color: var(--text-color); margin-bottom: 10px;">Bitcoin Ancrage Dashboard - Équipe 4NK</p>
<a href="/" style="color: var(--primary-color); text-decoration: none;">← Retour au Dashboard</a>
</footer>
</div>

View File

@ -6,11 +6,11 @@
:root {
--primary-color: #f7931a;
--secondary-color: #1a1a1a;
--background-color: #f5f5f5;
--card-background: #ffffff;
--text-color: #333333;
--border-color: #e0e0e0;
--secondary-color: #e0e0e0;
--background-color: #1a1a1a;
--card-background: #2d2d2d;
--text-color: #e0e0e0;
--border-color: #404040;
--success-color: #28a745;
--error-color: #dc3545;
--warning-color: #ffc107;
@ -36,7 +36,7 @@ header {
background: linear-gradient(135deg, var(--primary-color), #ff6b35);
color: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}
header h1 {
@ -98,13 +98,14 @@ section h2 {
background: var(--card-background);
border-radius: 10px;
padding: 25px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid var(--border-color);
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
.card h3 {
@ -174,6 +175,8 @@ textarea {
font-size: 1em;
margin-bottom: 15px;
transition: border-color 0.3s;
background-color: var(--card-background);
color: var(--text-color);
}
input[type="text"]:focus,
@ -205,7 +208,8 @@ button:hover {
}
button:disabled {
background-color: #ccc;
background-color: #555;
color: #888;
cursor: not-allowed;
}
@ -238,9 +242,10 @@ button:disabled {
.file-info {
margin: 15px 0;
padding: 10px;
background-color: var(--background-color);
background-color: var(--card-background);
border-radius: 5px;
font-size: 0.9em;
border: 1px solid var(--border-color);
}
.result {
@ -251,23 +256,23 @@ button:disabled {
}
.result.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
background-color: rgba(40, 167, 69, 0.2);
color: #90ee90;
border: 1px solid #28a745;
display: block;
}
.result.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
background-color: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
border: 1px solid #dc3545;
display: block;
}
.result.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
background-color: rgba(13, 202, 240, 0.2);
color: #6ec6ff;
border: 1px solid #0dcaf0;
display: block;
}
@ -276,15 +281,16 @@ footer {
padding: 20px;
background-color: var(--card-background);
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
color: #666;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #999;
border: 1px solid var(--border-color);
}
footer .git-link {
display: inline-block;
margin-top: 15px;
text-decoration: none;
color: #666;
color: #999;
transition: color 0.3s, transform 0.2s;
}
@ -332,7 +338,7 @@ footer .git-icon {
}
.security-warning {
background-color: #fff3cd;
background-color: rgba(255, 193, 7, 0.15);
border: 2px solid #ffc107;
border-radius: 5px;
padding: 15px;
@ -340,14 +346,14 @@ footer .git-icon {
}
.security-warning strong {
color: #856404;
color: #ffc107;
display: block;
margin-bottom: 8px;
}
.security-warning p {
margin: 0;
color: #856404;
color: #ffd54f;
font-size: 0.9em;
line-height: 1.5;
}

View File

@ -20,17 +20,18 @@
.header-section .back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
color: #6ec6ff;
text-decoration: none;
}
.header-section .back-link:hover {
text-decoration: underline;
}
.info-section {
background: #f8f9fa;
background: var(--card-background);
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border: 1px solid var(--border-color);
}
.info-section p {
margin: 5px 0;
@ -75,8 +76,9 @@
}
.table-container {
overflow-x: auto;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
background: var(--card-background);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
}
table {
width: 100%;
@ -85,24 +87,24 @@
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--border-color);
}
th {
background: #f8f9fa;
color: #333;
background: var(--card-background);
color: var(--text-color);
font-weight: bold;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f5f5;
background: rgba(255, 255, 255, 0.05);
}
.txid-cell {
font-family: monospace;
font-size: 0.9em;
}
.txid-link {
color: #007bff;
color: #6ec6ff;
text-decoration: none;
}
.txid-link:hover {
@ -121,24 +123,25 @@
text-align: center;
padding: 40px;
font-size: 1.2em;
color: #666;
color: var(--text-color);
}
.progress-section {
padding: 30px;
text-align: center;
background: #f8f9fa;
background: var(--card-background);
border-radius: 8px;
margin: 20px 0;
border: 1px solid var(--border-color);
}
.progress-section p {
margin: 10px 0;
color: #333;
color: var(--text-color);
}
.progress-bar-container {
width: 100%;
max-width: 400px;
height: 24px;
background: #e9ecef;
background: var(--border-color);
border-radius: 12px;
overflow: hidden;
margin: 15px auto;
@ -150,15 +153,16 @@
}
.progress-stats {
font-size: 0.9em;
color: #666;
color: var(--text-color);
margin-top: 10px;
}
.error {
background: #f8d7da;
color: #721c24;
background: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
border: 1px solid #dc3545;
}
.refresh-button {
background: #28a745;
@ -200,7 +204,7 @@
position: relative;
}
.sortable-header:hover {
background: #e9ecef;
background: rgba(255, 255, 255, 0.05);
}
.sort-arrow {
display: inline-block;
@ -240,9 +244,10 @@
.empty-message {
padding: 20px;
text-align: center;
color: #666;
background: #f8f9fa;
color: var(--text-color);
background: var(--card-background);
border-radius: 0 0 5px 5px;
border: 1px solid var(--border-color);
}
.status-spent {
color: #dc3545;
@ -257,7 +262,7 @@
font-weight: bold;
}
.navigation-bar {
background: #f8f9fa;
background: var(--card-background);
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
@ -265,20 +270,21 @@
flex-wrap: wrap;
gap: 10px;
justify-content: center;
border: 1px solid var(--border-color);
}
.navigation-bar a {
padding: 10px 20px;
background: white;
color: #007bff;
background: var(--card-background);
color: #6ec6ff;
text-decoration: none;
border-radius: 5px;
border: 2px solid #007bff;
border: 2px solid #6ec6ff;
transition: all 0.3s;
font-weight: bold;
}
.navigation-bar a:hover {
background: #007bff;
color: white;
background: #6ec6ff;
color: var(--background-color);
}
.category-section {
scroll-margin-top: 20px;
@ -298,8 +304,8 @@
<p><strong>Montant total :</strong> <span id="total-amount" class="total-amount">-</span></p>
<p><strong>Dernière mise à jour :</strong> <span id="last-update">-</span></p>
<button class="refresh-button" id="refresh-fast-button" onclick="loadUtxoList()">Actualisation Rapide</button>
<button class="refresh-button" id="refresh-detailed-button" onclick="loadUtxoListFromRPC()" style="margin-left: 10px; background: #007bff;">Actualisation détaillée</button>
<a href="/api/utxo/list.txt" download="utxo_list.txt" style="margin-left: 10px; color: #007bff; text-decoration: none;">📥 Télécharger le fichier texte</a>
<button class="refresh-button" id="refresh-detailed-button" onclick="loadUtxoListFromRPC()" style="margin-left: 10px; background: #6ec6ff; color: var(--background-color);">Actualisation détaillée</button>
<a href="/api/utxo/list.txt" download="utxo_list.txt" style="margin-left: 10px; color: #6ec6ff; text-decoration: none;">📥 Télécharger le fichier texte</a>
</div>
</div>

View File

@ -39,9 +39,27 @@ Types names chiffrés dont login
Action:
Message à valider
types name chiffrés dont action
+ Validateurs de l'action
+ Auteurs de l'action
+ contrats parents uuid, au moins 1
Objets créés lors de la création :
Lors de la création d'un Pair :
- Création automatique d'un Pair (MessageBase avec types_names_chiffres incluant "pair")
- Le Pair doit être associé à au moins un Membre (membres_parents_uuid, au moins 1)
Lors de la création d'un Membre :
- Création automatique d'un Membre (MessageAValider avec types_names_chiffres incluant "membre")
- Le Membre doit être associé à au moins une Action (actions_parents_uuid, au moins 1)
Lors de la création d'un contrat de login d'un Service :
- Création automatique d'un Contrat (MessageAValider avec types_names_chiffres incluant "contrat")
- Création automatique d'une ActionLogin (Action avec types_names_chiffres incluant "login")
- Création automatique d'un Membre (MessageAValider avec types_names_chiffres incluant "membre")
- L'ActionLogin est associée au Contrat (contrats_parents_uuid incluant l'UUID du Contrat)
- Le Membre est associé à l'ActionLogin (actions_parents_uuid incluant l'UUID de l'ActionLogin)
- Le nom du contrat de login est passé en paramètre initial à l'iframe ou au site, par défaut "default"
Membre :
Message à valider
types name chiffrés dont membre

View File

@ -6,7 +6,7 @@
## Synthèse
- **Relais (api-relay)** : Stockage hybride (mémoire + `./data/messages.json`). Messages, `seenHashes`, signatures et clés persistés. Sauvegarde à larrêt (SIGINT/SIGTERM) et périodique (configurable via `SAVE_INTERVAL_SECONDS`).
- **Front (userwallet)** : LocalStorage (`userwallet_identity`, `userwallet_relays`, `userwallet_pairs`, `userwallet_hash_cache` ; legacy : `userwallet_keypair`, `userwallet_services`). Graphe contractuel en mémoire uniquement (`GraphResolver`).
- **Front (userwallet)** : LocalStorage (`userwallet_identity`, `userwallet_relays`, `userwallet_pairs`, `userwallet_hash_cache` ; legacy : `userwallet_keypair`, `userwallet_services`). IndexedDB : `hash_cache`, `userwallet_pairing_confirm`. Graphe contractuel en mémoire uniquement (`GraphResolver`).
## Stockage sur le relais (api-relay)
@ -137,17 +137,23 @@ Le front utilise **LocalStorage** du navigateur pour toutes les données locales
uuid: string,
membres_parents_uuid: string[],
is_local: boolean,
can_sign: boolean
can_sign: boolean,
publicKey?: string // clé publique identité de l'autre device (ECDH pairing)
}>
```
4. **`userwallet_hash_cache`** : Cache des hash vus
4. **`userwallet_hash_cache`** : Cache des hash vus (IndexedDB, store `kv`)
```typescript
string[] // Array de hash
```
5. **`userwallet_keypair`** : (Legacy) Paire de clés
6. **`userwallet_services`** : (Legacy) Services configurés
5. **`userwallet_pairing_confirm`** : Confirmations de pairing (IndexedDB, store `kv`)
```typescript
Array<{ pairLocal: string, pairRemote: string, hash: string, version: number }>
```
6. **`userwallet_keypair`** : (Legacy) Paire de clés
7. **`userwallet_services`** : (Legacy) Services configurés
### Données en mémoire (non persistées)
@ -164,11 +170,20 @@ Le front utilise **LocalStorage** du navigateur pour toutes les données locales
### Export / Import
- **Export** : Écran « Export / Import données » (`/data`). Télécharge un JSON (identité, relais, pairs, hash_cache, keypair, services).
- **Import** : Même écran, bouton « Choisir un fichier ». Remplace les données locales puis recharge la page.
- **Export** : Écran « Export / Import données » (`/data`). Télécharge un JSON (identité, relais, pairs, hash_cache, pairing_confirm, keypair, services). `hash_cache` et `pairing_confirm` lus depuis IndexedDB.
- **Import** : Même écran, bouton « Choisir un fichier ». Remplace les données locales (`hash_cache`, `pairing_confirm` écrits en IndexedDB si présents dans lexport) puis recharge la page.
### Recommandations
### Protection par mot de passe
- Chiffrer les clés privées avec un mot de passe utilisateur
- Utiliser IndexedDB pour des données plus volumineuses
- Implémenter une synchronisation cloud optionnelle (chiffrée)
- **Activation** : Écran « Export / Import données » → section « Protection par mot de passe ». Mot de passe + confirmation (min. 8 caractères). Chiffrement AES-GCM (clé dérivée PBKDF2-HMAC-SHA256, 100k itérations).
- **Déverrouillage** : Si protection activée, écran « Déverrouiller » affiché tant que la session nest pas ouverte. Mot de passe → clé en session, puis accès aux écrans normaux.
- **Verrouillage** : Bouton « Verrouiller » (accueil ou /data). Vide la session uniquement ; stockage chiffré inchangé.
- **Désactivation** : /data → « Désactiver la protection » (mot de passe actuel). Déchiffrement et réécriture de lidentité avec clé en clair.
### IndexedDB
- **Base** : `userwallet`, store `kv` (key/value).
- **Utilisation** : `hash_cache`, `userwallet_pairing_confirm` (confirmations de pairing). `utils/indexedDbStorage` expose `idbGet`, `idbSet`, `idbRemove`.
- **Migration** : Au premier `HashCache.init()`, si IndexedDB vide et `localStorage` contient `userwallet_hash_cache`, copie vers IndexedDB puis suppression du localStorage.
Pas de synchronisation cloud (hors scope, jamais).

View File

@ -47,7 +47,7 @@ Spécification du login décentralisé (secp256k1, contrats, pairing mFA, relais
### Points notables
- **Identité** : création (secp256k1), import (dérivation clé publique depuis clé privée hex ; mnemonic/seed non supportés).
- **Identité** : création (secp256k1), import (clé hex 64 car. ou phrase BIP39, dérivation m/44'/0'/0'/0/0 ; passphrase BIP39 non supportée).
- **Relais** : config dans LocalStorage, test `/health`, GET/POST messages/signatures/keys via `utils/relay`.
- **Pairing** : BIP32 UUID ↔ mots, `PairConfig` avec `membres_parents_uuid`, `is_local`, `can_sign`.
- **Graphe** : GraphResolver avec caches (services, contrats, champs, actions, membres, pairs), `resolveLoginPath`, validation des parents.

View File

@ -0,0 +1,52 @@
# Pairing "Connecté" — confirmation croisée et affichage
## Objectif
Afficher "Connecté" sur chaque device une fois que le flux de confirmation signé de lautre device a été reçu et traité. La confirmation repose sur un message "membre finaliser" signé par les deux devices, publié sur le relais avec version incrémentée, et stockée en IndexedDB.
## Rappel
Les messages sont envoyés essentiellement chiffrés. Il faut avoir reçu des messages de clé (Diffie-Hellman) pour déchiffrer la clé de chiffrement et, via le hash, récupérer sur le relais le message qui nous concerne.
## Flux attendu
### Premier device
1. Après "Associer le pair", le 1er device a ajouté la clé publique du 2e (pair distant).
2. Le 1er device envoie un **message "membre finaliser"** quil signe.
3. Ce message doit aussi être signé par le 2e device.
4. Le 1er device **attend la signature du 2e device** sur ce contrat.
5. Une fois reçue, il lajoute aux signatures du contrat, vérifie que toutes les signatures attendues sont présentes, puis **publie le contrat sur le relais** (à nouveau), avec une **version incrémentée**.
6. Il garde la version (et le contrat) dans **IndexedDB**.
7. Quand tout est OK (flux signé du 2e reçu et traité) → afficher **"Connecté"** sur le 1er device.
### Deuxième device
1. Après "Associer le pair", le 2e device a ajouté la clé publique du 1er (pair distant).
2. Le 2e device envoie un **message "membre finalisé"** quil signe.
3. Il **envoie sa signature au 1er device** (via relais : message/clé/signature).
4. Il complète le contrat avec sa signature, **incrémente la version**, et le garde dans **IndexedDB**.
5. Quand tout est OK (flux signé du 1er reçu et traité) → afficher **"Connecté"** sur le 2e device.
## Impacts
- **Contrats** : format "membre finaliser" / "membre finalisé", validateurs, signatures multiples.
- **Relais** : publication message + signatures + clés (chiffrement, DH).
- **IndexedDB** : stockage des versions et des contrats signés.
- **Sync** : récupération des messages chiffrés, clés DH, déchiffrement, mise à jour du graphe et détection des confirmations croisées.
## Modalités de déploiement
- Déploiement classique du front userwallet.
- Les relais doivent exposer POST message, POST signatures, POST keys (déjà prévus).
- Aucune migration IndexedDB imposée pour lexistant tant que le protocole nest pas implémenté.
## Modalités danalyse
- Vérifier les logs (console) pour les pairs et contrats.
- Tester le parcours "Associer le pair" sur les deux devices puis vérifier laffichage "Connecté" une fois les signatures croisées reçues et le contrat publié.
## Statut
- **Documentation** : présente.
- **Implémentation** : faite (message "membre finaliser", échange de signatures, publication relais, stockage IndexedDB, affichage "Connecté"). Version incrémentée : device 1 re-publie M2 (version "2") après réception de la signature du 2e. Détection au Sync : si device 1 a timeout au poll, un « Synchroniser maintenant » vérifie messages + signatures et enregistre la confirmation. **Chiffrement ECDH** : échange de clés publiques (QR/URL `pubkey`, formulaire « Clé du 2e »), `PairConfig.publicKey`, chiffrement `encryptWithECDH`, POST `MsgCle` (algo `AES-GCM-ECDH`, `df_ecdh_scannable` = clé du signataire), déchiffrement au fetch. Type "membre finalisé" (device 2) : non implémenté, les deux signent le même "membre finaliser". **Export/import** : `pairing_confirm` inclus dans lexport, restauré à limport ; suppression (« Supprimer ») vide aussi `userwallet_pairing_confirm`.

View File

@ -10,12 +10,16 @@
"dependencies": {
"@noble/hashes": "^1.3.0",
"@noble/secp256k1": "^2.0.0",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
@ -924,6 +928,33 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -1349,6 +1380,66 @@
"win32"
]
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1408,6 +1499,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1415,6 +1516,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
@ -1713,7 +1824,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -1723,7 +1833,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -2050,6 +2159,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@ -2088,11 +2206,21 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -2105,7 +2233,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@ -2216,6 +2343,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2259,6 +2395,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -2307,6 +2449,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@ -3081,6 +3229,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -3597,6 +3754,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@ -4297,6 +4463,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4314,7 +4489,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4377,6 +4551,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -4448,6 +4631,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -4587,6 +4787,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@ -4789,6 +5004,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -4971,6 +5192,20 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -5073,7 +5308,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@ -5291,6 +5525,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -5475,6 +5716,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@ -5507,6 +5754,20 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@ -5514,6 +5775,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -5521,6 +5788,93 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -12,24 +12,28 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@noble/hashes": "^1.3.0",
"qrcode": "^1.5.4",
"@noble/secp256k1": "^2.0.0",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"@noble/secp256k1": "^2.0.0",
"@noble/hashes": "^1.3.0"
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@eslint/js": "^9.0.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-unused-imports": "^3.0.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"@vitejs/plugin-react": "^4.2.1"
"vite": "^5.0.8"
}
}

View File

@ -1,16 +1,33 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import {
BrowserRouter,
Routes,
Route,
useLocation,
} from 'react-router-dom';
import { PairingWordsProvider } from './contexts/PairingWordsContext';
import { GlobalActionBar } from './components/GlobalActionBar';
import { HomeScreen } from './components/HomeScreen';
import { CreateIdentityScreen } from './components/CreateIdentityScreen';
import { ImportIdentityScreen } from './components/ImportIdentityScreen';
import { PairingDisplayScreen } from './components/PairingDisplayScreen';
import { RelaySettingsScreen } from './components/RelaySettingsScreen';
import { PairManagementScreen } from './components/PairManagementScreen';
import { SyncScreen } from './components/SyncScreen';
import { LoginScreen } from './components/LoginScreen';
import { ServiceListScreen } from './components/ServiceListScreen';
import { DataExportImportScreen } from './components/DataExportImportScreen';
import { UnlockScreen } from './components/UnlockScreen';
import { useChannel } from './hooks/useChannel';
import { useIdentity } from './hooks/useIdentity';
import './index.css';
const PAIRING_DISPLAY_PATH = '/pairing-display';
function usePairingDisplayBypass(): boolean {
const location = useLocation();
return location.pathname === PAIRING_DISPLAY_PATH;
}
function AppContent(): JSX.Element {
useChannel();
@ -29,10 +46,36 @@ function AppContent(): JSX.Element {
);
}
function AppGate(): JSX.Element {
const { identity, isProtected, isUnlocked, isLoading } = useIdentity();
if (isLoading) {
return (
<div role="status" aria-live="polite" aria-busy="true">
Chargement
</div>
);
}
if (identity !== null && isProtected && !isUnlocked) {
return <UnlockScreen />;
}
return <AppContent />;
}
function AppRoot(): JSX.Element {
const bypass = usePairingDisplayBypass();
return (
<PairingWordsProvider>
<GlobalActionBar />
{bypass ? <PairingDisplayScreen /> : <AppGate />}
</PairingWordsProvider>
);
}
export function App(): JSX.Element {
return (
<BrowserRouter>
<AppContent />
<AppRoot />
</BrowserRouter>
);
}

View File

@ -53,13 +53,6 @@ export function CreateIdentityScreen(): JSX.Element {
</button>
</div>
</form>
<section aria-labelledby="info-heading">
<h2 id="info-heading">Information</h2>
<p>
Une paire de clés secp256k1 sera générée. La clé privée sera stockée
localement et ne sera jamais transmise.
</p>
</section>
</main>
);
}

View File

@ -1,28 +1,79 @@
import { useNavigate } from 'react-router-dom';
import { useRef, useState } from 'react';
import { useRef, useState, FormEvent } from 'react';
import {
exportUserWalletData,
importUserWalletData,
downloadExportFile,
} from '../utils/exportImport';
function downloadExport(json: string): void {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `userwallet-export-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
import { useIdentity } from '../hooks/useIdentity';
export function DataExportImportScreen(): JSX.Element {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const {
identity,
isProtected,
isUnlocked,
enableProtection,
disableProtection,
lock,
} = useIdentity();
const [protectPassword, setProtectPassword] = useState('');
const [protectConfirm, setProtectConfirm] = useState('');
const [protectError, setProtectError] = useState<string | null>(null);
const [protectLoading, setProtectLoading] = useState(false);
const [disablePassword, setDisablePassword] = useState('');
const [disableError, setDisableError] = useState<string | null>(null);
const [disableLoading, setDisableLoading] = useState(false);
const handleExport = (): void => {
const json = exportUserWalletData();
downloadExport(json);
const handleEnableProtection = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setProtectError(null);
if (protectPassword !== protectConfirm) {
setProtectError('Les mots de passe ne correspondent pas.');
return;
}
if (protectPassword.length < 8) {
setProtectError('Mot de passe d\'au moins 8 caractères.');
return;
}
setProtectLoading(true);
try {
await enableProtection(protectPassword);
setProtectPassword('');
setProtectConfirm('');
} catch (err) {
setProtectError(err instanceof Error ? err.message : 'Erreur');
} finally {
setProtectLoading(false);
}
};
const handleDisableProtection = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setDisableError(null);
setDisableLoading(true);
try {
await disableProtection(disablePassword);
setDisablePassword('');
} catch (err) {
setDisableError(err instanceof Error ? err.message : 'Erreur');
} finally {
setDisableLoading(false);
}
};
const [exportError, setExportError] = useState<string | null>(null);
const handleExport = async (): Promise<void> => {
setExportError(null);
try {
const json = await exportUserWalletData();
downloadExportFile(json);
} catch (err) {
setExportError(err instanceof Error ? err.message : 'Erreur export');
}
};
const handleImportClick = (): void => {
@ -37,14 +88,14 @@ export function DataExportImportScreen(): JSX.Element {
return;
}
const reader = new FileReader();
reader.onload = () => {
reader.onload = async () => {
const text = reader.result;
if (typeof text !== 'string') {
setImportError('Fichier non lisible');
return;
}
try {
importUserWalletData(text);
await importUserWalletData(text);
window.location.reload();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Erreur import');
@ -65,6 +116,11 @@ export function DataExportImportScreen(): JSX.Element {
<button type="button" onClick={handleExport}>
Exporter les données
</button>
{exportError !== null && (
<p role="alert" style={{ color: 'var(--color-error)' }}>
{exportError}
</p>
)}
</section>
<section aria-labelledby="import-section">
<h2 id="import-section">Import</h2>
@ -89,6 +145,83 @@ export function DataExportImportScreen(): JSX.Element {
</p>
)}
</section>
{identity !== null && (
<section aria-labelledby="protection-section">
<h2 id="protection-section">Protection par mot de passe</h2>
{!isProtected ? (
<form onSubmit={handleEnableProtection} aria-label="Activer la protection">
<p>Chiffre la clé privée. Déverrouillage requis pour signer.</p>
<div>
<label htmlFor="protect-pw">
Mot de passe
<input
id="protect-pw"
type="password"
value={protectPassword}
onChange={(e) => setProtectPassword(e.target.value)}
minLength={8}
autoComplete="new-password"
/>
</label>
</div>
<div>
<label htmlFor="protect-confirm">
Confirmer
<input
id="protect-confirm"
type="password"
value={protectConfirm}
onChange={(e) => setProtectConfirm(e.target.value)}
minLength={8}
autoComplete="new-password"
/>
</label>
</div>
{protectError !== null && (
<p role="alert" style={{ color: 'var(--color-error)' }}>
{protectError}
</p>
)}
<button type="submit" disabled={protectLoading}>
{protectLoading ? 'Activation…' : 'Activer la protection'}
</button>
</form>
) : (
<>
<p>Protection activée.</p>
<form onSubmit={handleDisableProtection} aria-label="Désactiver la protection">
<div>
<label htmlFor="disable-pw">
Mot de passe actuel
<input
id="disable-pw"
type="password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
autoComplete="current-password"
/>
</label>
</div>
{disableError !== null && (
<p role="alert" style={{ color: 'var(--color-error)' }}>
{disableError}
</p>
)}
<button type="submit" disabled={disableLoading}>
{disableLoading ? 'Désactivation…' : 'Désactiver la protection'}
</button>
</form>
{isUnlocked && (
<p>
<button type="button" onClick={lock}>
Verrouiller
</button>
</p>
)}
</>
)}
</section>
)}
<p>
<button type="button" onClick={() => navigate('/')}>
Retour à l&apos;accueil

View File

@ -12,10 +12,11 @@ export function ErrorDisplay({ error, onDismiss }: ErrorDisplayProps): JSX.Eleme
aria-live="assertive"
style={{
padding: '1rem',
backgroundColor: '#fee',
border: '1px solid #fcc',
backgroundColor: 'var(--color-error-bg, #fee2e2)',
border: '1px solid var(--color-error-border, #fca5a5)',
borderRadius: '0.5rem',
marginBottom: '1rem',
color: 'var(--color-text)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>

View File

@ -0,0 +1,198 @@
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useIdentity } from '../hooks/useIdentity';
import { storePairs, getLocalPairWords } from '../utils/pairing';
import { clearLocalIdentity } from '../utils/identity';
import { clearPairingConfirm } from '../services/pairingConfirm';
import {
exportUserWalletData,
downloadExportFile,
} from '../utils/exportImport';
import { usePairingWordsContext } from '../contexts/PairingWordsContext';
function modalOverlayStyle(): CSSProperties {
return {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
};
}
function modalPanelStyle(): CSSProperties {
return {
background: 'var(--color-background, #fff)',
color: 'var(--color-text)',
padding: '1.5rem',
borderRadius: '8px',
maxWidth: '320px',
boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
};
}
export function GlobalActionBar(): JSX.Element {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { identity, refreshIdentity } = useIdentity();
const ctx = usePairingWordsContext();
const [showExportConfirm, setShowExportConfirm] = useState(false);
const [exportConfirmError, setExportConfirmError] = useState<string | null>(null);
const [showMots, setShowMots] = useState(false);
// Detect iframe context
const isInIframe = typeof window !== 'undefined' && window.self !== window.top;
// Read URL parameters to control button visibility
const showDeleteParam = searchParams.get('showDelete') === 'true';
const showWordsParam = searchParams.get('showWords') === 'true';
// Display logic: show by default (normal usage), hide in iframe unless forced by URL params
const shouldShowDelete = !isInIframe || showDeleteParam;
const shouldShowWords = !isInIframe || showWordsParam;
const performDelete = async (): Promise<void> => {
clearLocalIdentity();
storePairs([]);
await clearPairingConfirm();
setShowExportConfirm(false);
setExportConfirmError(null);
refreshIdentity();
navigate('/');
};
const handleExportThenDelete = async (): Promise<void> => {
setExportConfirmError(null);
try {
const json = await exportUserWalletData();
downloadExportFile(json);
await performDelete();
} catch (err) {
setExportConfirmError(err instanceof Error ? err.message : 'Erreur export');
}
};
const handleNoExportDelete = (): void => {
void performDelete();
};
const mots = ctx?.offerWords ?? getLocalPairWords();
const hasMots = mots !== null && mots.length > 0;
return (
<>
<nav
role="navigation"
aria-label="Actions globales"
style={{
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
padding: '0.5rem',
borderBottom: `1px solid var(--color-border)`,
marginBottom: '0.5rem',
}}
>
{identity !== null && shouldShowDelete && (
<button
type="button"
onClick={() => {
setExportConfirmError(null);
setShowExportConfirm(true);
}}
title="Supprimer identité et pairs. Proposition d'export avant suppression."
aria-label="Supprimer identité et pairs"
>
Supprimer
</button>
)}
{shouldShowWords && (
<button
type="button"
onClick={() => setShowMots(true)}
title="Afficher les 8 mots (représentation clé publique, format type BIP32)"
aria-label="Afficher les mots de ma clé publique"
>
Afficher les mots de ma clé publique
</button>
)}
</nav>
{showExportConfirm && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="export-confirm-title"
style={modalOverlayStyle()}
>
<div style={modalPanelStyle()}>
<h2 id="export-confirm-title">
Voulez-vous exporter vos données avant de supprimer ?
</h2>
{exportConfirmError !== null && (
<p role="alert" style={{ color: 'var(--color-error)' }}>
{exportConfirmError}
</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
onClick={() => void handleExportThenDelete()}
>
Oui
</button>
<button type="button" onClick={handleNoExportDelete}>
Non
</button>
<button
type="button"
onClick={() => {
setShowExportConfirm(false);
setExportConfirmError(null);
}}
>
Annuler
</button>
</div>
</div>
</div>
)}
{showMots && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="mots-modal-title"
style={modalOverlayStyle()}
>
<div style={modalPanelStyle()}>
<h2 id="mots-modal-title">Mots de ma clé publique</h2>
{hasMots ? (
<p
aria-label="Mots clé publique"
style={{
fontFamily: 'monospace',
fontSize: '1rem',
wordBreak: 'break-word',
margin: '1rem 0',
}}
>
{mots.join(' ')}
</p>
) : (
<p style={{ margin: '1rem 0' }}>
Aucune paire locale. Configurez le pairing pour afficher les mots.
</p>
)}
<button type="button" onClick={() => setShowMots(false)}>
Fermer
</button>
</div>
</div>
)}
</>
);
}

View File

@ -1,14 +1,78 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useIdentity } from '../hooks/useIdentity';
import { isPairingSatisfied } from '../utils/pairing';
import { isPairingSatisfied, hasRemotePair } from '../utils/pairing';
import { usePairingConnected } from '../hooks/usePairingConnected';
import { getStoredRelays } from '../utils/relay';
import type { LocalIdentity } from '../types/identity';
import { PairingSetupBlock } from './PairingSetupBlock';
function logHomeStatus(
identity: LocalIdentity,
pairingSatisfied: boolean,
relayStatus: string,
relayCount: number,
): void {
const name = identity.name !== undefined ? `\nNom: ${identity.name}` : '';
const pairingLine = pairingSatisfied
? 'Satisfait: Oui'
: 'Satisfait: Non\n⚠ Pairing obligatoire avant de pouvoir se connecter';
const msg = [
'[UserWallet] Home — statut (log):',
'UserWallet Login',
'Statut identité',
'Présente: Oui',
`Clé publique: ${identity.publicKey.slice(0, 16)}...`,
name,
'Statut pairing',
'Requis: Oui',
pairingLine,
'Statut réseau relais',
`Statut: ${relayStatus}`,
relayCount > 0 ? `Nombre de relais: ${relayCount}` : '',
]
.filter(Boolean)
.join('\n');
console.warn(msg);
}
export function HomeScreen(): JSX.Element {
const navigate = useNavigate();
const { identity, isLoading } = useIdentity();
const { connected: pairingConnected } = usePairingConnected();
const pairingSatisfied = isPairingSatisfied();
const showSetupBlock = !hasRemotePair();
const relays = getStoredRelays();
const relayStatus = relays.length > 0 ? 'OK' : 'Non configuré';
const pairingSetupRef = useRef<HTMLElement | null>(null);
const hasScrolledAndLoggedRef = useRef(false);
useEffect(() => {
if (identity === null) {
return;
}
logHomeStatus(identity, pairingSatisfied, relayStatus, relays.length);
}, [identity, pairingSatisfied, relayStatus, relays.length]);
useEffect(() => {
if (!showSetupBlock || identity === null || hasScrolledAndLoggedRef.current) {
if (!showSetupBlock) {
hasScrolledAndLoggedRef.current = false;
}
return;
}
hasScrolledAndLoggedRef.current = true;
console.warn('[UserWallet] Redirection vers la section QR (pairing-setup)');
window.location.hash = 'pairing-setup';
const scrollToSection = (): void => {
pairingSetupRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
};
const t = window.setTimeout(scrollToSection, 100);
return () => window.clearTimeout(t);
}, [showSetupBlock, identity]);
if (isLoading) {
return (
@ -21,7 +85,6 @@ export function HomeScreen(): JSX.Element {
if (identity === null) {
return (
<main>
<h1>UserWallet Login</h1>
<p>Identité locale absente</p>
<div>
<button onClick={() => navigate('/create-identity')}>
@ -30,9 +93,6 @@ export function HomeScreen(): JSX.Element {
<button onClick={() => navigate('/import-identity')}>
Importer une identité
</button>
<button onClick={() => navigate('/data')}>
Export / Import données
</button>
</div>
</main>
);
@ -40,64 +100,20 @@ export function HomeScreen(): JSX.Element {
return (
<main>
<h1>UserWallet Login</h1>
<section aria-labelledby="identity-status">
<h2 id="identity-status">Statut identité</h2>
<p>
<strong>Présente:</strong> Oui
</p>
<p>
<strong>Clé publique:</strong>{' '}
<code>{identity.publicKey.slice(0, 16)}...</code>
</p>
{identity.name !== undefined && (
<p>
<strong>Nom:</strong> {identity.name}
{pairingConnected && (
<p role="status" style={{ fontWeight: 'bold', color: 'var(--color-ok, var(--color-success))' }}>
Connecté
</p>
)}
{showSetupBlock && (
<section
ref={pairingSetupRef}
id="pairing-setup"
aria-labelledby="pairing-setup-heading"
>
<PairingSetupBlock />
</section>
<section aria-labelledby="pairing-status">
<h2 id="pairing-status">Statut pairing</h2>
<p>
<strong>Requis:</strong> Oui
</p>
<p>
<strong>Satisfait:</strong> {pairingSatisfied ? 'Oui' : 'Non'}
</p>
{!pairingSatisfied && (
<p role="alert">
Pairing obligatoire avant de pouvoir se connecter
</p>
)}
</section>
<section aria-labelledby="network-status">
<h2 id="network-status">Statut réseau relais</h2>
<p>
<strong>Statut:</strong> {relayStatus}
</p>
{relays.length > 0 && (
<p>
<strong>Nombre de relais:</strong> {relays.length}
</p>
)}
</section>
<section aria-labelledby="actions">
<h2 id="actions">Actions</h2>
<div>
<button onClick={() => navigate('/login')} disabled={!pairingSatisfied}>
Se connecter
</button>
<button onClick={() => navigate('/manage-pairs')}>Gérer les pairs</button>
<button onClick={() => navigate('/relay-settings')}>
Réglages relais
</button>
<button onClick={() => navigate('/sync')}>Synchroniser maintenant</button>
<button onClick={() => navigate('/services')}>Services disponibles</button>
<button onClick={() => navigate('/data')}>
Export / Import données
</button>
</div>
</section>
</main>
);
}

View File

@ -35,18 +35,23 @@ export function ImportIdentityScreen(): JSX.Element {
<form onSubmit={handleSubmit} aria-label="Formulaire d'import d'identité">
<div>
<label htmlFor="seed">
Seed / Phrase / Clé privée
Seed / Phrase BIP39 / Clé privée
<textarea
id="seed"
value={seedOrKey}
onChange={(e) => {
setSeedOrKey(e.target.value);
}}
placeholder="Entrez votre seed, phrase ou clé privée"
placeholder="Clé hex 64 car. ou phrase BIP39 (12/24 mots anglais)"
rows={4}
required
aria-describedby="seed-hint"
/>
</label>
<p id="seed-hint" className="hint">
Clé hex brute ou phrase BIP39 (anglais). Passphrase BIP39 non
supportée. Dérivation m/44&apos;/0&apos;/0&apos;/0/0.
</p>
</div>
<div>
<label htmlFor="name">

View File

@ -94,6 +94,11 @@ export function LoginScreen(): JSX.Element {
pair_uuid: string;
}> = [];
const pk = identity.privateKey;
if (pk === undefined) {
handleError('Clé privée indisponible. Déverrouillez l\'identité.', 'IDENTITY_LOCKED');
return;
}
for (const req of loginPath.signatures_requises) {
if (req.pair_uuid === undefined) {
handleError('Pair UUID manquant pour une signature requise', 'MISSING_PAIR_UUID');
@ -102,7 +107,7 @@ export function LoginScreen(): JSX.Element {
const sig = loginBuilder.signChallenge(
challenge,
req.pair_uuid,
identity.privateKey,
pk,
);
signatures.push({
signature: sig,

View File

@ -1,117 +1,98 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
getStoredPairs,
createLocalPair,
addRemotePairFromWords,
} from '../utils/pairing';
import type { PairConfig } from '../types/identity';
import { useEffect } from 'react';
import { getStoredPairs } from '../utils/pairing';
import { getStoredRelays } from '../utils/relay';
import { useIdentity } from '../hooks/useIdentity';
import { GraphResolver } from '../services/graphResolver';
import { SyncService } from '../services/syncService';
export function PairManagementScreen(): JSX.Element {
const navigate = useNavigate();
const [pairs, setPairs] = useState<PairConfig[]>([]);
const [showAddLocal, setShowAddLocal] = useState(false);
const [showAddRemote, setShowAddRemote] = useState(false);
const [words, setWords] = useState<string[]>([]);
const [wordInput, setWordInput] = useState('');
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'));
}
useEffect(() => {
setPairs(getStoredPairs());
}, []);
const handleCreateLocal = (): void => {
const { pair, words: generatedWords } = createLocalPair([]);
setPairs([...pairs, pair]);
setWords(generatedWords);
setShowAddLocal(true);
};
const handleAddRemote = (): void => {
const wordList = wordInput
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (wordList.length === 0) {
async function logContrats(identity: { t0_anniversaire: number }): Promise<void> {
const relays = getStoredRelays().filter((r) => r.enabled);
if (relays.length === 0) {
console.warn('[UserWallet] Log contrats: aucun relais activé.');
return;
}
const pair = addRemotePairFromWords(wordList, []);
if (pair === null) {
alert('Mots invalides. Vérifiez la saisie.');
const graphResolver = new GraphResolver();
const syncService = new SyncService(relays, graphResolver);
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;
}
setPairs([...pairs, pair]);
setWordInput('');
setShowAddRemote(false);
};
return (
<main>
<h1>Gestion des pairs</h1>
<section aria-labelledby="pairs-list">
<h2 id="pairs-list">Pairs configurés</h2>
{pairs.length === 0 ? (
<p>Aucun pair configuré</p>
) : (
<ul role="list">
{pairs.map((pair) => (
<li key={pair.uuid}>
<div>
<p>
<strong>UUID:</strong> {pair.uuid.slice(0, 16)}...
</p>
<p>
<strong>Local:</strong> {pair.is_local ? 'Oui' : 'Non'}
</p>
<p>
<strong>Peut signer:</strong> {pair.can_sign ? 'Oui' : 'Non'}
</p>
</div>
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="add-pair">
<h2 id="add-pair">Ajouter un pair</h2>
<div>
<button onClick={handleCreateLocal}>Ce device devient un pair</button>
<button onClick={() => setShowAddRemote(true)}>
Associer un pair existant
</button>
</div>
{showAddLocal && words.length > 0 && (
<div role="alert">
<p>
<strong>Mots BIP32 à saisir sur l'autre device:</strong>
</p>
<p>{words.join(' ')}</p>
<button onClick={() => setShowAddLocal(false)}>Fermer</button>
</div>
)}
{showAddRemote && (
<div>
<label htmlFor="words">
Saisir les mots BIP32 affichés par l'autre device
<textarea
id="words"
value={wordInput}
onChange={(e) => {
setWordInput(e.target.value);
}}
placeholder="mot1 mot2 mot3 ..."
rows={3}
/>
</label>
<div>
<button onClick={handleAddRemote}>Reconstituer UUID</button>
<button onClick={() => setShowAddRemote(false)}>Annuler</button>
</div>
</div>
)}
</section>
<div>
<button onClick={() => navigate('/')}>Retour</button>
</div>
</main>
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 {
const { identity } = useIdentity();
useEffect(() => {
logPairsConfig();
if (identity !== null) {
void logContrats(identity).catch((err: unknown) => {
console.error('[UserWallet] Log contrats failed:', err);
});
}
}, [identity]);
return <></>;
}

View File

@ -0,0 +1,287 @@
import { useState, FormEvent, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
addRemotePairFromWords,
ensureLocalPairForSetup,
getStoredPairs,
parseAndValidatePairingWords,
validatePublicKeyHex,
} from '../utils/pairing';
import { bip32WordsToUuid } from '../utils/bip32';
import { usePairingWordsContext } from '../contexts/PairingWordsContext';
import { useIdentity } from '../hooks/useIdentity';
import { usePairingConnected } from '../hooks/usePairingConnected';
import { getStoredRelays } from '../utils/relay';
import { runDevice2Confirmation } from '../services/pairingConfirm';
const WORDS_PARAM = 'words';
const PUBKEY_PARAM = 'pubkey';
const EXPECTED_WORD_COUNT = 8;
function parseWordsFromSearch(search: URLSearchParams): string[] | null {
const raw = search.get(WORDS_PARAM);
if (raw === null || raw === '') {
return null;
}
try {
const decoded = decodeURIComponent(raw);
const words = decoded
.split(',')
.map((w) => w.trim().toLowerCase())
.filter((w) => w.length > 0);
if (words.length !== EXPECTED_WORD_COUNT) {
return null;
}
const uuid = bip32WordsToUuid(words);
return uuid !== null ? words : null;
} catch {
return null;
}
}
function parsePubkeyFromSearch(search: URLSearchParams): string | null {
const raw = search.get(PUBKEY_PARAM);
if (raw === null || raw === '') {
return null;
}
try {
const decoded = decodeURIComponent(raw).trim();
return decoded.length > 0 ? decoded : null;
} catch {
return null;
}
}
export function PairingDisplayScreen(): JSX.Element {
const [searchParams] = useSearchParams();
const ctx = usePairingWordsContext();
const { identity, isLoading, createNewIdentity } = useIdentity();
const { connected: pairingConnected } = usePairingConnected();
const prefilled = parseWordsFromSearch(searchParams);
const pubkeyFromUrl = parsePubkeyFromSearch(searchParams);
const [words2nd, setWords2nd] = useState<string[]>([]);
const [wordInput, setWordInput] = useState(() =>
prefilled !== null ? prefilled.join(' ') : '',
);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [hasCopiedToFirstDevice, setHasCopiedToFirstDevice] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [justConnected, setJustConnected] = useState(false);
useEffect(() => {
const w = ensureLocalPairForSetup();
setWords2nd(w);
}, []);
useEffect(() => {
if (isLoading || identity !== null) {
return;
}
createNewIdentity();
}, [isLoading, identity, createNewIdentity]);
useEffect(() => {
ctx?.setOfferWords(words2nd);
return () => {
ctx?.setOfferWords(null);
};
}, [ctx, words2nd]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setError(null);
const parsed = parseAndValidatePairingWords(wordInput);
if (parsed === null) {
setError('Mots invalides. 8 mots requis, représentation clé publique.');
return;
}
const pubkey = pubkeyFromUrl?.trim() ?? '';
if (!validatePublicKeyHex(pubkey)) {
setError('Clé publique du 1ᵉʳ appareil absente ou invalide. Scannez le QR ou vérifiez lURL.');
return;
}
const pair = addRemotePairFromWords(parsed, [], pubkey);
if (pair === null) {
setError('Mots invalides. Vérifiez la saisie.');
return;
}
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
const remote = pairs.find((p) => !p.is_local);
if (
identity === null ||
local === undefined ||
remote === undefined ||
identity.privateKey === undefined
) {
setSuccess(true);
return;
}
const relays = getStoredRelays().filter((r) => r.enabled);
if (relays.length === 0) {
setError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
return;
}
setIsConfirming(true);
try {
const ok = await runDevice2Confirmation(
local.uuid,
remote.uuid,
identity,
relays,
identity.t0_anniversaire,
Date.now(),
remote.publicKey,
);
setJustConnected(ok);
} catch (err) {
console.error('Pairing confirmation (device 2):', err);
setError(
err instanceof Error ? err.message : 'Erreur lors de la confirmation du pairing.',
);
setIsConfirming(false);
return;
}
setIsConfirming(false);
setSuccess(true);
};
if (success) {
const showConnected = pairingConnected || justConnected;
return (
<main>
{showConnected && (
<p role="status" style={{ fontWeight: 'bold', color: 'var(--color-ok, green)' }}>
Connecté
</p>
)}
<h1>Pair associé</h1>
<p>Le pair du 1ʳ appareil a é ajouté.</p>
<p>
<Link to="/">Accueil</Link> <Link to="/manage-pairs">Gérer les pairs</Link>
</p>
</main>
);
}
const words2ndText = words2nd.length > 0 ? words2nd.join(' ') : '—';
return (
<main>
<h1>Saisir les mots du 1ʳ appareil</h1>
<div
role="region"
aria-labelledby="words-2nd-heading"
id="mots-2e-appareil"
style={{
display: 'block',
marginTop: '1.5rem',
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: 'var(--color-info-bg, #dbeafe)',
border: '2px solid var(--color-info-border, #93c5fd)',
borderRadius: '8px',
color: 'var(--color-text)',
}}
>
<h2 id="words-2nd-heading" style={{ marginTop: 0, marginBottom: '0.5rem' }}>
Mots et clé du 2 appareil à copier sur le 1ʳ
</h2>
<p className="hint" style={{ marginBottom: '0.5rem' }}>
8 mots (format type BIP32) et clé publique (66 hex).
</p>
<p
aria-label="Mots 2e appareil"
style={{
fontFamily: 'monospace',
fontSize: '1.1rem',
wordBreak: 'break-word',
margin: 0,
padding: '0.75rem',
backgroundColor: 'var(--color-background)',
color: 'var(--color-text)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
{words2ndText}
</p>
{identity?.publicKey !== undefined && (
<>
<p className="hint" style={{ marginTop: '1rem', marginBottom: '0.25rem' }}>
Clé publique du 2 appareil
</p>
<p
aria-label="Clé publique 2e appareil"
style={{
fontFamily: 'monospace',
fontSize: '0.9rem',
wordBreak: 'break-all',
margin: 0,
padding: '0.5rem',
backgroundColor: 'var(--color-background)',
color: 'var(--color-text)',
borderRadius: '4px',
border: '1px solid var(--color-info-border, #93c5fd)',
}}
>
{identity.publicKey}
</p>
</>
)}
{!hasCopiedToFirstDevice ? (
<p style={{ marginTop: '1rem', marginBottom: 0 }}>
<button
type="button"
onClick={() => setHasCopiedToFirstDevice(true)}
>
J&apos;ai copié ces mots sur mon premier device
</button>
</p>
) : null}
</div>
{!hasCopiedToFirstDevice && (
<p>
<Link to="/">Accueil</Link> <Link to="/manage-pairs">Gérer les pairs</Link>
</p>
)}
{hasCopiedToFirstDevice && (
<>
<p>
Saisissez les 8 mots affichés par le 1ʳ appareil (représentation de la
clé publique, format type BIP32). Si vous avez scanné le QR, les mots
peuvent être pré-remplis.
</p>
<form
onSubmit={(ev) => void handleSubmit(ev)}
aria-label="Saisir les mots du 1er appareil"
>
<label htmlFor="pairing-words-display">
Mots du 1ʳ appareil
<textarea
id="pairing-words-display"
value={wordInput}
onChange={(e) => setWordInput(e.target.value)}
placeholder="mot1 mot2 mot3 ..."
rows={3}
aria-describedby={error !== null ? 'pairing-display-err' : undefined}
/>
</label>
{error !== null && (
<p id="pairing-display-err" role="alert" style={{ color: 'var(--color-error)' }}>
{error}
</p>
)}
<button type="submit" disabled={isConfirming}>
{isConfirming ? 'Finalisation…' : 'Associer le pair'}
</button>
</form>
<p>
<Link to="/">Accueil</Link> <Link to="/manage-pairs">Gérer les pairs</Link>
</p>
</>
)}
</main>
);
}

View File

@ -0,0 +1,213 @@
import { useEffect, useMemo, useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import QRCode from 'qrcode';
import {
ensureLocalPairForSetup,
addRemotePairFromWords,
getStoredPairs,
parseAndValidatePairingWords,
validatePublicKeyHex,
} from '../utils/pairing';
import { useIdentity } from '../hooks/useIdentity';
import { getStoredRelays } from '../utils/relay';
import { runDevice1Confirmation } from '../services/pairingConfirm';
const PAIRING_DISPLAY_PATH = '/pairing-display';
const WORDS_PARAM = 'words';
const PUBKEY_PARAM = 'pubkey';
const QR_SIZE = 256;
function buildPairingDisplayUrl(words: string[], pubkey?: string): string {
const base = `${window.location.origin}${PAIRING_DISPLAY_PATH}`;
const encoded = encodeURIComponent(words.join(','));
let url = `${base}?${WORDS_PARAM}=${encoded}`;
if (pubkey !== undefined && pubkey !== '') {
url += `&${PUBKEY_PARAM}=${encodeURIComponent(pubkey)}`;
}
return url;
}
export function PairingSetupBlock(): JSX.Element {
const navigate = useNavigate();
const { identity } = useIdentity();
const [words, setWords] = useState<string[]>([]);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [remoteWordsInput, setRemoteWordsInput] = useState('');
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();
setWords(w);
}, []);
const url = useMemo(
() => buildPairingDisplayUrl(words, identity?.publicKey),
[words, identity?.publicKey],
);
useEffect(() => {
if (words.length === 0) {
return;
}
QRCode.toDataURL(url, { width: QR_SIZE })
.then(setQrDataUrl)
.catch((err: unknown) => {
console.error('QR generation failed:', err);
});
}, [words, url]);
const handleSubmitRemote = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setRemoteError(null);
const parsed = parseAndValidatePairingWords(remoteWordsInput);
if (parsed === null) {
setRemoteError('Mots invalides. 8 mots requis, représentation clé publique.');
return;
}
const pubkey = remotePubkeyInput.trim();
if (!validatePublicKeyHex(pubkey)) {
setRemoteError('Clé publique invalide. 66 caractères hex, préfixe 02 ou 03.');
return;
}
const pair = addRemotePairFromWords(parsed, [], pubkey);
if (pair === null) {
setRemoteError('Mots invalides. Vérifiez la saisie.');
return;
}
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
const remote = pairs.find((p) => !p.is_local);
if (
identity === null ||
local === undefined ||
remote === undefined ||
identity.privateKey === undefined
) {
setRemoteWordsInput('');
setRemotePubkeyInput('');
navigate('/manage-pairs');
return;
}
const relays = getStoredRelays().filter((r) => r.enabled);
if (relays.length === 0) {
setRemoteError('Aucun relais activé. Configurez les relais pour finaliser le pairing.');
return;
}
setIsConfirming(true);
try {
await runDevice1Confirmation(
local.uuid,
remote.uuid,
identity,
relays,
remote.publicKey,
);
} catch (err) {
console.error('Pairing confirmation (device 1):', err);
setRemoteError(
err instanceof Error ? err.message : 'Erreur lors de la confirmation du pairing.',
);
setIsConfirming(false);
return;
}
setIsConfirming(false);
setRemoteWordsInput('');
setRemotePubkeyInput('');
navigate('/manage-pairs');
};
return (
<div role="region" aria-labelledby="pairing-setup-heading">
<h3 id="pairing-setup-heading">Configurer le pairing avec un 2 appareil</h3>
<p>
Scannez le QR (ou ouvrez lURL) sur le 2 appareil pour accéder à la
saisie des mots du 1ʳ. Saisissez cidessous les mots du 2 (représentation
de la clé publique, 8 mots, format type BIP32).
</p>
{words.length > 0 && (
<>
<p>
<strong>Mots du 1ʳ appareil</strong> à saisir sur le 2 (QR) :
</p>
<p
aria-label="Mots 1er appareil"
style={{ fontFamily: 'monospace', fontSize: '1rem', wordBreak: 'break-word' }}
>
{words.join(' ')}
</p>
{qrDataUrl !== null && (
<p>
<img
src={qrDataUrl}
alt="QR code : URL pour saisir les mots du 1er appareil sur le 2e"
width={QR_SIZE}
height={QR_SIZE}
/>
</p>
)}
<p>
<strong>URL pour le 2 appareil :</strong>{' '}
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
</p>
{!hasCopiedToSecondDevice ? (
<p>
<button
type="button"
onClick={() => setHasCopiedToSecondDevice(true)}
>
J&apos;ai copié ces mots sur mon deuxième device
</button>
</p>
) : (
<section aria-labelledby="remote-words-heading">
<h4 id="remote-words-heading">Mots et clé publique du 2 appareil</h4>
<p className="hint">
8 mots (format type BIP32) et clé publique (66 hex, 02/03).
</p>
<form
onSubmit={(ev) => void handleSubmitRemote(ev)}
aria-label="Saisir les mots et clé du 2e appareil"
>
<label htmlFor="remote-pairing-words">
Mots affichés par le 2 appareil
<textarea
id="remote-pairing-words"
value={remoteWordsInput}
onChange={(e) => setRemoteWordsInput(e.target.value)}
placeholder="mot1 mot2 mot3 ..."
rows={3}
aria-describedby={remoteError !== null ? 'remote-words-err' : undefined}
/>
</label>
<label htmlFor="remote-pairing-pubkey">
Clé publique du 2 appareil (hex)
<input
id="remote-pairing-pubkey"
type="text"
value={remotePubkeyInput}
onChange={(e) => setRemotePubkeyInput(e.target.value)}
placeholder="02a01e4330b58215..."
aria-describedby={remoteError !== null ? 'remote-words-err' : undefined}
/>
</label>
{remoteError !== null && (
<p id="remote-words-err" role="alert" style={{ color: 'var(--color-error)' }}>
{remoteError}
</p>
)}
<button type="submit" disabled={isConfirming}>
{isConfirming ? 'Finalisation…' : 'Associer le pair'}
</button>
</form>
</section>
)}
</>
)}
</div>
);
}

View File

@ -35,6 +35,7 @@ export function ServiceListScreen(): JSX.Element {
const graphResolver = new GraphResolver();
const syncService = new SyncService(relays, graphResolver);
await syncService.init();
const start = identity.t0_anniversaire;
const end = Date.now();

View File

@ -4,8 +4,10 @@ import { useIdentity } from '../hooks/useIdentity';
import { useErrorHandler } from '../hooks/useErrorHandler';
import { ErrorDisplay } from './ErrorDisplay';
import { getStoredRelays } from '../utils/relay';
import { getStoredPairs } from '../utils/pairing';
import { SyncService } from '../services/syncService';
import { GraphResolver } from '../services/graphResolver';
import { checkPairingConfirmationFromSync } from '../services/pairingConfirm';
export function SyncScreen(): JSX.Element {
const navigate = useNavigate();
@ -36,6 +38,7 @@ export function SyncScreen(): JSX.Element {
const graphResolver = new GraphResolver();
const syncService = new SyncService(relays, graphResolver);
await syncService.init();
const start = identity.t0_anniversaire;
const end = Date.now();
@ -43,6 +46,27 @@ export function SyncScreen(): JSX.Element {
const result = await syncService.sync(start, end);
setStats(result);
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
const remote = pairs.find((p) => !p.is_local);
if (
local !== undefined &&
remote !== undefined &&
identity.privateKey !== undefined
) {
void checkPairingConfirmationFromSync(
relays,
local.uuid,
remote.uuid,
identity,
start,
end,
remote.publicKey,
).catch((err: unknown) => {
console.error('Pairing confirmation check during sync:', err);
});
}
if (result.messages === 0) {
handleError('Aucun message récupéré. Vérifiez la connectivité des relais.', 'NO_MESSAGES');
}
@ -65,9 +89,6 @@ export function SyncScreen(): JSX.Element {
jusqu'à maintenant
</p>
)}
</section>
<section aria-labelledby="sync-actions">
<h2 id="sync-actions">Actions</h2>
<button onClick={handleSync} disabled={isSyncing || identity === null}>
{isSyncing ? 'Synchronisation...' : 'Synchroniser maintenant'}
</button>

View File

@ -0,0 +1,63 @@
import { useState, FormEvent } from 'react';
import { useIdentity } from '../hooks/useIdentity';
export function UnlockScreen(): JSX.Element {
const { identity, unlock } = useIdentity();
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isUnlocking, setIsUnlocking] = useState(false);
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setError(null);
setIsUnlocking(true);
try {
await unlock(password);
setPassword('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur de déverrouillage');
} finally {
setIsUnlocking(false);
}
};
if (identity === null) {
return (
<main>
<p>Aucune identité.</p>
</main>
);
}
return (
<main>
<h1>Déverrouiller l&apos;identité</h1>
<p>
Clé publique : <code>{identity.publicKey.slice(0, 20)}...</code>
</p>
<form onSubmit={handleSubmit} aria-label="Déverrouillage par mot de passe">
<div>
<label htmlFor="unlock-password">
Mot de passe
<input
id="unlock-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
</div>
{error !== null && (
<p role="alert" style={{ color: 'var(--color-error)' }}>
{error}
</p>
)}
<button type="submit" disabled={isUnlocking}>
{isUnlocking ? 'Déverrouillage…' : 'Déverrouiller'}
</button>
</form>
</main>
);
}

View File

@ -0,0 +1,32 @@
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from 'react';
type PairingWordsContextValue = {
/** Words from 2nd device (pairing-display). Null when not on that screen. */
offerWords: string[] | null;
setOfferWords: (words: string[] | null) => void;
};
const PairingWordsContext = createContext<PairingWordsContextValue | null>(null);
export function PairingWordsProvider({ children }: { children: ReactNode }): JSX.Element {
const [offerWords, setOfferWords] = useState<string[] | null>(null);
const value: PairingWordsContextValue = {
offerWords,
setOfferWords: useCallback((w: string[] | null) => setOfferWords(w), []),
};
return (
<PairingWordsContext.Provider value={value}>
{children}
</PairingWordsContext.Provider>
);
}
export function usePairingWordsContext(): PairingWordsContextValue | null {
return useContext(PairingWordsContext);
}

View File

@ -15,11 +15,14 @@ export function useChannel() {
const handleAuthRequest = useCallback(
(_message: AuthRequestMessage): void => {
if (identity === null) {
if (identity === null || identity.privateKey === undefined) {
sendToChannel({
type: 'error',
payload: {
message: 'Identité non disponible',
message:
identity === null
? 'Identité non disponible'
: 'Identité verrouillée. Déverrouillez d\'abord.',
code: 'NO_IDENTITY',
},
});

View File

@ -1,24 +1,57 @@
import { useState, useEffect } from 'react';
import { getStoredIdentity, createIdentity, importIdentity } from '../utils/identity';
import { useState, useEffect, useCallback } from 'react';
import {
getStoredIdentity,
createIdentity,
importIdentity,
isProtectionEnabled,
enableProtection as enableProtectionImpl,
disableProtection as disableProtectionImpl,
unlock as unlockImpl,
lock as lockImpl,
} from '../utils/identity';
import { getUnlockedPrivateKey } from '../utils/sessionUnlockedKey';
import type { LocalIdentity } from '../types/identity';
function mergeIdentityWithSession(
stored: LocalIdentity | null,
): LocalIdentity | null {
if (stored === null) {
return null;
}
if (stored.privateKey !== undefined) {
return stored;
}
const sessionKey = getUnlockedPrivateKey();
if (sessionKey === null) {
return stored;
}
return { ...stored, privateKey: sessionKey };
}
export function useIdentity() {
const [identity, setIdentity] = useState<LocalIdentity | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const refreshIdentity = useCallback((): void => {
const stored = getStoredIdentity();
setIdentity(stored);
setIsLoading(false);
setIdentity(mergeIdentityWithSession(stored));
}, []);
useEffect(() => {
refreshIdentity();
setIsLoading(false);
}, [refreshIdentity]);
const createNewIdentity = (name?: string): LocalIdentity => {
const newIdentity = createIdentity(name);
setIdentity(newIdentity);
return newIdentity;
};
const importExistingIdentity = (seedOrPrivateKey: string, name?: string): LocalIdentity | null => {
const importExistingIdentity = (
seedOrPrivateKey: string,
name?: string,
): LocalIdentity | null => {
const imported = importIdentity(seedOrPrivateKey, name);
if (imported !== null) {
setIdentity(imported);
@ -26,10 +59,47 @@ export function useIdentity() {
return imported;
};
const enableProtection = useCallback(
async (password: string): Promise<void> => {
await enableProtectionImpl(password);
refreshIdentity();
},
[refreshIdentity],
);
const disableProtection = useCallback(
async (password: string): Promise<void> => {
await disableProtectionImpl(password);
refreshIdentity();
},
[refreshIdentity],
);
const unlock = useCallback(
async (password: string): Promise<void> => {
await unlockImpl(password);
refreshIdentity();
},
[refreshIdentity],
);
const lock = useCallback((): void => {
lockImpl();
refreshIdentity();
}, [refreshIdentity]);
return {
identity,
isLoading,
isProtected: isProtectionEnabled(),
isUnlocked:
isProtectionEnabled() && getUnlockedPrivateKey() !== null,
createNewIdentity,
importExistingIdentity,
enableProtection,
disableProtection,
unlock,
lock,
refreshIdentity,
};
}

View File

@ -0,0 +1,53 @@
import { useState, useEffect } from 'react';
import { getStoredPairs } from '../utils/pairing';
import { getPairingConfirmed } from '../services/pairingConfirm';
/**
* True when we have exactly one local and one remote pair and that pairing
* is confirmed (both devices signed, stored in IndexedDB).
*/
export function usePairingConnected(): {
connected: boolean;
loading: boolean;
} {
const [connected, setConnected] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function check(): Promise<void> {
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
const remote = pairs.find((p) => !p.is_local);
if (local === undefined || remote === undefined) {
if (!cancelled) {
setConnected(false);
setLoading(false);
}
return;
}
try {
const ok = await getPairingConfirmed(local.uuid, remote.uuid);
if (!cancelled) {
setConnected(ok);
}
} catch {
if (!cancelled) {
setConnected(false);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void check();
return () => {
cancelled = true;
};
}, []);
return { connected, loading };
}

View File

@ -5,25 +5,32 @@
}
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-secondary: #64748b;
--color-success: #10b981;
--color-error: #ef4444;
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-border: #e2e8f0;
--color-focus: #2563eb;
--color-primary: #f7931a;
--color-primary-hover: #e8840a;
--color-secondary: #6b7280;
--color-success: #28a745;
--color-ok: #28a745;
--color-error: #dc3545;
--color-background: #1a1a1a;
--color-surface: #2d2d2d;
--color-text: #e0e0e0;
--color-text-secondary: #b0b0b0;
--color-border: #404040;
--color-focus: #f7931a;
--color-error-bg: rgba(220, 53, 69, 0.2);
--color-error-border: #dc3545;
--color-success-bg: rgba(40, 167, 69, 0.2);
--color-success-border: #28a745;
--color-info-bg: rgba(13, 202, 240, 0.15);
--color-info-border: #0dcaf0;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--border-radius: 0.5rem;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
}
body {
@ -63,9 +70,17 @@ input {
font-family: inherit;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-sm) var(--spacing-md);
padding: var(--spacing-md);
font-size: 1rem;
transition: border-color 0.2s;
background-color: var(--color-surface);
color: var(--color-text);
width: 100%;
}
input::placeholder {
color: var(--color-text-secondary);
opacity: 0.6;
}
input:focus {
@ -82,27 +97,86 @@ main {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-xl);
width: 100%;
}
main > div {
margin-top: var(--spacing-xl);
}
main > div:first-child {
margin-top: 0;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
color: var(--color-text);
line-height: 1.2;
}
h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: var(--spacing-xl);
margin-bottom: var(--spacing-md);
margin-top: 0;
margin-bottom: var(--spacing-lg);
color: var(--color-text);
line-height: 1.3;
}
section {
margin-top: var(--spacing-xl);
padding: var(--spacing-lg);
padding: var(--spacing-xl);
background-color: var(--color-surface);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
section > div {
margin-top: var(--spacing-lg);
}
section > div:first-of-type {
margin-top: 0;
}
section > div > div {
margin-bottom: var(--spacing-md);
}
section > div > div:last-child {
margin-bottom: 0;
}
section label {
margin-bottom: var(--spacing-md);
}
section label:last-of-type {
margin-bottom: 0;
}
section button {
margin-top: var(--spacing-md);
width: 100%;
}
form {
margin-top: var(--spacing-xl);
}
form > div {
margin-bottom: var(--spacing-lg);
}
form > div:last-of-type {
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
ul[role="list"] {
@ -115,9 +189,18 @@ li {
label {
display: flex;
align-items: center;
gap: var(--spacing-md);
flex-direction: column;
gap: var(--spacing-sm);
cursor: pointer;
margin-bottom: var(--spacing-lg);
font-weight: 500;
color: var(--color-text);
}
label:has(input[type="checkbox"]) {
flex-direction: row;
align-items: center;
margin-bottom: var(--spacing-md);
}
input[type="checkbox"] {
@ -140,8 +223,32 @@ button[type="submit"]:hover:not(:disabled) {
}
button[type="submit"]:disabled {
opacity: 0.5;
opacity: 0.6;
cursor: not-allowed;
background-color: var(--color-secondary);
color: var(--color-background);
}
button:disabled:not([type="submit"]) {
opacity: 0.6;
cursor: not-allowed;
background-color: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
button:not([type="submit"]):not(:disabled) {
background-color: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
padding: var(--spacing-md) var(--spacing-xl);
font-size: 1rem;
font-weight: 500;
width: 100%;
}
button:not([type="submit"]):not(:disabled):hover {
background-color: var(--color-border);
}
p {
@ -151,12 +258,29 @@ p {
word-break: break-all;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: #0f172a;
--color-surface: #1e293b;
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
}
p.hint {
font-size: 0.8125rem;
margin-top: var(--spacing-xs);
}
a,
a:link,
a:visited,
a:active {
color: white;
text-decoration: none;
}
a:hover,
a:focus {
color: var(--color-primary);
text-decoration: underline;
}
a:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: 2px;
}
/* Dark theme is now the default with Bitcoin colors */

View File

@ -94,6 +94,27 @@ export class GraphResolver {
return Array.from(this.cache.membres.values());
}
/**
* Get all contrats from cache.
*/
getContrats(): Contrat[] {
return Array.from(this.cache.contrats.values());
}
/**
* Get all pairs from cache.
*/
getPairs(): Pair[] {
return Array.from(this.cache.pairs.values());
}
/**
* Get all actions from cache.
*/
getActions(): Action[] {
return Array.from(this.cache.actions.values());
}
/**
* Resolve login path: Service Contrat Champ ActionLogin Membre Pair.
*/

View File

@ -0,0 +1,594 @@
import { hashStringAsync } from '../utils/canonical';
import { signMessage } from '../utils/crypto';
import { verifyMessageSignatures } from '../utils/verification';
import { idbGet, idbSet, idbRemove } from '../utils/indexedDbStorage';
import {
getMessagesChiffres,
getSignatures,
getKeys,
postMessageChiffre,
postSignature,
postKey,
} from '../utils/relay';
import { encryptWithECDH, decryptWithECDH } from '../utils/encryption';
import { generateUuid } from '../utils/bip32';
import type { RelayConfig } from '../types/identity';
import type { LocalIdentity } from '../types/identity';
import type {
MessageBase,
MsgChiffre,
MsgCle,
MsgSignature,
Signature,
} from '../types/message';
const ECDH_ALGO = 'AES-GCM-ECDH';
const POLL_ATTEMPTS = 15;
const POLL_DELAY_MS = 2000;
const IDB_KEY = 'userwallet_pairing_confirm';
const TYPE_MEMBRE_FINALISER = 'membre finaliser';
const VERSION_V1 = '1';
const VERSION_V2 = '2';
const VERSION_LOGICIELLE = '1.0.0';
export interface PairingConfirmedRecord {
pairLocal: string;
pairRemote: string;
hash: string;
version: number;
}
interface MembreFinaliserMessage {
uuid: string;
version: string;
types: { types_uuid: string[]; types_names_chiffres: string };
datajson: {
services_uuid: string[];
types_uuid: string[];
pair_a: string;
pair_b: string;
timestamp: number;
};
timestamp: number;
liste_relais: string[];
version_logicielle: string;
}
function sortedPairKey(a: string, b: string): string {
return [a, b].sort().join('|');
}
function messageUuid(pairLocal: string, pairRemote: string): string {
return `pairing-${sortedPairKey(pairLocal, pairRemote)}`;
}
async function loadRecords(): Promise<PairingConfirmedRecord[]> {
const raw = await idbGet(IDB_KEY);
if (raw === null) {
return [];
}
try {
const parsed = JSON.parse(raw) as PairingConfirmedRecord[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function saveRecords(records: PairingConfirmedRecord[]): Promise<void> {
await idbSet(IDB_KEY, JSON.stringify(records));
}
/**
* Create "membre finaliser" message. Deterministic uuid from pair pair.
*/
export async function createMembreFinaliserMessage(
pairLocal: string,
pairRemote: string,
relays: string[],
): Promise<{ message: MembreFinaliserMessage; hash: string }> {
const uuid = messageUuid(pairLocal, pairRemote);
const pairA = sortedPairKey(pairLocal, pairRemote).split('|')[0] as string;
const pairB = sortedPairKey(pairLocal, pairRemote).split('|')[1] as string;
const timestamp = Date.now();
const message: MembreFinaliserMessage = {
uuid,
version: VERSION_V1,
types: {
types_uuid: [uuid],
types_names_chiffres: TYPE_MEMBRE_FINALISER,
},
datajson: {
services_uuid: [],
types_uuid: [uuid],
pair_a: pairA,
pair_b: pairB,
timestamp,
},
timestamp,
liste_relais: relays,
version_logicielle: VERSION_LOGICIELLE,
};
const canonical = JSON.stringify(message);
const hash = await hashStringAsync(canonical, 'sha256');
return { message, hash };
}
/**
* Sign message hash. Returns Signature for relay.
*/
export function signMembreFinaliser(
hash: string,
identity: LocalIdentity,
nonce: string,
): Signature {
const pk = identity.privateKey;
if (pk === undefined) {
throw new Error('Private key required to sign pairing confirmation');
}
const messageToSign = `${hash}-${nonce}`;
const signatureHex = signMessage(messageToSign, pk);
return {
hash,
cle_publique: identity.publicKey,
signature: signatureHex,
nonce,
};
}
async function encryptPairingMessage(
message: MembreFinaliserMessage,
recipientPublicKey: string,
senderIdentity: LocalIdentity,
): Promise<{ encrypted: string; iv: string; senderPublicKey: string }> {
const pk = senderIdentity.privateKey;
if (pk === undefined) {
throw new Error('Private key required to encrypt pairing message');
}
const payload = JSON.stringify(message);
const out = await encryptWithECDH(payload, recipientPublicKey, pk);
return {
encrypted: out.encrypted,
iv: out.iv,
senderPublicKey: out.publicKey,
};
}
function buildMsgCle(
hashMessage: string,
iv: string,
senderPublicKey: string,
): MsgCle {
return {
hash_message: hashMessage,
cle_de_chiffrement_message: {
algo: ECDH_ALGO,
params: { iv: iv },
},
df_ecdh_scannable: senderPublicKey,
};
}
/**
* Publish a pairing message (MsgChiffre) to relays.
* When recipientPublicKey and senderIdentity are provided, encrypt with ECDH and POST MsgCle.
*/
async function publishPairingMessage(
relays: RelayConfig[],
message: MembreFinaliserMessage,
hash: string,
recipientPublicKey?: string,
senderIdentity?: LocalIdentity,
): Promise<void> {
const enabled = relays.filter((r) => r.enabled);
if (enabled.length === 0) {
throw new Error('No enabled relays');
}
let messageChiffre: string;
let msgCle: MsgCle | null = null;
if (
recipientPublicKey !== undefined &&
recipientPublicKey !== '' &&
senderIdentity !== undefined
) {
const { encrypted, iv, senderPublicKey } = await encryptPairingMessage(
message,
recipientPublicKey,
senderIdentity,
);
messageChiffre = encrypted;
msgCle = buildMsgCle(hash, iv, senderPublicKey);
} else {
messageChiffre = btoa(JSON.stringify(message));
}
const msgChiffre: MsgChiffre = {
hash,
message_chiffre: messageChiffre,
datajson_public: message.datajson,
};
for (const r of enabled) {
await postMessageChiffre(r.endpoint, msgChiffre);
if (msgCle !== null) {
await postKey(r.endpoint, msgCle);
}
}
}
/**
* Publish message and first signature to relays.
* When recipientPublicKey and senderIdentity are set, message is ECDH-encrypted and MsgCle posted.
*/
export async function publishPairingMessageAndSignature(
relays: RelayConfig[],
message: MembreFinaliserMessage,
hash: string,
sig: Signature,
recipientPublicKey?: string,
senderIdentity?: LocalIdentity,
): Promise<void> {
await publishPairingMessage(
relays,
message,
hash,
recipientPublicKey,
senderIdentity,
);
const enabled = relays.filter((r) => r.enabled);
const msgSig: MsgSignature = { signature: sig };
for (const r of enabled) {
await postSignature(r.endpoint, msgSig);
}
}
/**
* Publish a signature only (second device adds its signature).
*/
export async function publishPairingSignature(
relays: RelayConfig[],
sig: Signature,
): Promise<void> {
const enabled = relays.filter((r) => r.enabled);
if (enabled.length === 0) {
throw new Error('No enabled relays');
}
const msgSig: MsgSignature = { signature: sig };
for (const r of enabled) {
await postSignature(r.endpoint, msgSig);
}
}
/**
* Fetch messages in time window, find "membre finaliser" v1 for our pairs.
* When senderPublicKey and ourIdentity are provided, try ECDH decryption.
*/
export async function fetchPairingMessage(
relays: RelayConfig[],
pairLocal: string,
pairRemote: string,
start: number,
end: number,
senderPublicKey?: string,
ourIdentity?: LocalIdentity,
): Promise<{ message: MembreFinaliserMessage; hash: string } | null> {
const enabled = relays.filter((r) => r.enabled);
const key = sortedPairKey(pairLocal, pairRemote);
const useEcdh =
senderPublicKey !== undefined &&
senderPublicKey !== '' &&
ourIdentity !== undefined &&
ourIdentity.privateKey !== undefined;
for (const r of enabled) {
try {
const msgs = await getMessagesChiffres(r.endpoint, start, end);
for (const m of msgs) {
const pairA = (m.datajson_public as { pair_a?: string }).pair_a;
const pairB = (m.datajson_public as { pair_b?: string }).pair_b;
if (pairA === undefined || pairB === undefined) {
continue;
}
const k = sortedPairKey(pairA, pairB);
if (k !== key) {
continue;
}
let parsed: MembreFinaliserMessage;
if (useEcdh) {
const keys = await getKeys(r.endpoint, m.hash);
let decrypted: string | null = null;
for (const kc of keys) {
const algo = kc.cle_de_chiffrement_message?.algo;
const params = kc.cle_de_chiffrement_message?.params as { iv?: string } | undefined;
const iv = params?.iv;
const senderPub = kc.df_ecdh_scannable;
if (algo !== ECDH_ALGO || iv === undefined || senderPub === undefined) {
continue;
}
if (
senderPublicKey !== undefined &&
senderPublicKey !== '' &&
senderPub.toLowerCase() !== senderPublicKey.trim().toLowerCase()
) {
continue;
}
try {
decrypted = await decryptWithECDH(
m.message_chiffre,
iv,
senderPub,
ourIdentity.privateKey as string,
);
break;
} catch {
continue;
}
}
if (decrypted === null) {
continue;
}
parsed = JSON.parse(decrypted) as MembreFinaliserMessage;
} else {
const raw = atob(m.message_chiffre);
parsed = JSON.parse(raw) as MembreFinaliserMessage;
}
if (parsed.types?.types_names_chiffres !== TYPE_MEMBRE_FINALISER) {
continue;
}
if (parsed.version !== VERSION_V1) {
continue;
}
return { message: parsed, hash: m.hash };
}
} catch (e) {
console.error('fetchPairingMessage', r.endpoint, e);
}
}
return null;
}
/**
* Fetch signatures for a message hash from relays.
*/
export async function fetchSignaturesForHash(
relays: RelayConfig[],
hash: string,
): Promise<Signature[]> {
const enabled = relays.filter((r) => r.enabled);
const all: Signature[] = [];
for (const r of enabled) {
try {
const list = await getSignatures(r.endpoint, hash);
for (const { signature } of list) {
all.push(signature);
}
} catch (e) {
console.error('fetchSignaturesForHash', r.endpoint, e);
}
}
return all;
}
/**
* Check we have at least two distinct signers (our key + remote).
*/
function hasTwoDistinctSignatures(
sigs: Signature[],
ourPublicKey: string,
): boolean {
const keys = new Set(sigs.map((s) => s.cle_publique));
return keys.size >= 2 && keys.has(ourPublicKey);
}
/**
* Build MessageBase-like for verifyMessageSignatures (needs hash).
*/
function messageWithHash(
msg: MembreFinaliserMessage,
hash: string,
): MessageBase {
return {
...msg,
hash: { algo: 'sha256', hash_value: hash },
};
}
/**
* Build M2 (version 2) from M (version 1) and publish to relays.
* Device 1 calls this after receiving remote signature.
*/
async function buildAndPublishPairingVersion2(
relays: RelayConfig[],
message: MembreFinaliserMessage,
recipientPublicKey?: string,
senderIdentity?: LocalIdentity,
): Promise<void> {
const m2: MembreFinaliserMessage = {
...message,
version: VERSION_V2,
};
const canonical = JSON.stringify(m2);
const hash2 = await hashStringAsync(canonical, 'sha256');
await publishPairingMessage(
relays,
m2,
hash2,
recipientPublicKey,
senderIdentity,
);
}
/**
* Store confirmation and return updated records.
*/
export async function storePairingConfirmed(
pairLocal: string,
pairRemote: string,
hash: string,
version: number,
): Promise<void> {
const records = await loadRecords();
const key = sortedPairKey(pairLocal, pairRemote);
const existing = records.find(
(r) => sortedPairKey(r.pairLocal, r.pairRemote) === key,
);
if (existing !== undefined) {
return;
}
records.push({ pairLocal, pairRemote, hash, version });
await saveRecords(records);
}
/**
* True if we have a confirmed pairing for this (local, remote) pair.
*/
export async function getPairingConfirmed(
pairLocal: string,
pairRemote: string,
): Promise<boolean> {
const records = await loadRecords();
const key = sortedPairKey(pairLocal, pairRemote);
return records.some((r) => sortedPairKey(r.pairLocal, r.pairRemote) === key);
}
/**
* Clear all pairing confirmations from IndexedDB (e.g. on "Supprimer").
*/
export async function clearPairingConfirm(): Promise<void> {
await idbRemove(IDB_KEY);
}
function delay(ms: number): Promise<void> {
return new Promise((r) => {
setTimeout(r, ms);
});
}
/**
* Run confirmation flow for device 1: create M, sign, publish, poll for remote sig, store.
* remotePublicKey: identity public key of device 2, for ECDH encryption.
*/
export async function runDevice1Confirmation(
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
relays: RelayConfig[],
remotePublicKey?: string,
): Promise<boolean> {
const { message, hash } = await createMembreFinaliserMessage(
pairLocal,
pairRemote,
relays.map((r) => r.endpoint),
);
const n = generateUuid();
const sig = signMembreFinaliser(hash, identity, n);
await publishPairingMessageAndSignature(
relays,
message,
hash,
sig,
remotePublicKey,
identity,
);
for (let i = 0; i < POLL_ATTEMPTS; i++) {
if (i > 0) {
await delay(POLL_DELAY_MS);
}
const sigs = await fetchSignaturesForHash(relays, hash);
const msg = messageWithHash(message, hash);
const { valid } = verifyMessageSignatures(msg, sigs);
if (hasTwoDistinctSignatures(valid, identity.publicKey)) {
await buildAndPublishPairingVersion2(
relays,
message,
remotePublicKey,
identity,
);
await storePairingConfirmed(pairLocal, pairRemote, hash, 2);
return true;
}
}
return false;
}
/**
* Run confirmation flow for device 2: fetch M, verify sig1, sign, publish sig2, store.
* remotePublicKey: identity public key of device 1 (sender), for ECDH decryption.
*/
export async function runDevice2Confirmation(
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
relays: RelayConfig[],
start: number,
end: number,
remotePublicKey?: string,
): Promise<boolean> {
const found = await fetchPairingMessage(
relays,
pairLocal,
pairRemote,
start,
end,
remotePublicKey,
identity,
);
if (found === null) {
return false;
}
const { message, hash } = found;
const sigs = await fetchSignaturesForHash(relays, hash);
const msg = messageWithHash(message, hash);
const { valid } = verifyMessageSignatures(msg, sigs);
if (valid.length === 0) {
return false;
}
const n = generateUuid();
const sig = signMembreFinaliser(hash, identity, n);
await publishPairingSignature(relays, sig);
await storePairingConfirmed(pairLocal, pairRemote, hash, 2);
return true;
}
/**
* Check for pairing confirmation during Sync (device 1 timed out, user runs Sync later).
* Fetches pairing message v1, signatures; if both signers present, stores confirmation.
* remotePublicKey: identity public key of the other device (sender), for ECDH decryption.
*/
export async function checkPairingConfirmationFromSync(
relays: RelayConfig[],
pairLocal: string,
pairRemote: string,
identity: LocalIdentity,
start: number,
end: number,
remotePublicKey?: string,
): Promise<boolean> {
const already = await getPairingConfirmed(pairLocal, pairRemote);
if (already) {
return true;
}
const found = await fetchPairingMessage(
relays,
pairLocal,
pairRemote,
start,
end,
remotePublicKey,
identity,
);
if (found === null) {
return false;
}
const { message, hash } = found;
const sigs = await fetchSignaturesForHash(relays, hash);
const msg = messageWithHash(message, hash);
const { valid } = verifyMessageSignatures(msg, sigs);
if (!hasTwoDistinctSignatures(valid, identity.publicKey)) {
return false;
}
await storePairingConfirmed(pairLocal, pairRemote, hash, 2);
return true;
}

View File

@ -31,6 +31,13 @@ export class SyncService {
this.hashCache = new HashCache();
}
/**
* Initialize HashCache (load from IndexedDB). Call before sync().
*/
async init(): Promise<void> {
await this.hashCache.init();
}
/**
* Synchronize messages from all enabled relays.
*/
@ -48,6 +55,7 @@ export class SyncService {
let newMessages = 0;
let decrypted = 0;
let validated = 0;
const newHashes: string[] = [];
for (const relay of this.relays) {
if (!relay.enabled) {
@ -66,6 +74,7 @@ export class SyncService {
messages++;
if (!this.hashCache.hasSeen(msg.hash)) {
newMessages++;
newHashes.push(msg.hash);
this.hashCache.markSeen(msg.hash);
const decryptedMsg = await this.tryDecrypt(msg);
@ -84,6 +93,10 @@ export class SyncService {
}
}
if (newHashes.length > 0) {
await this.hashCache.markSeenBatch(newHashes);
}
return { messages, newMessages, decrypted, validated };
}

View File

@ -1,9 +1,10 @@
/**
* Local identity with secp256k1 key pair.
* When protection by password is enabled, privateKey is absent until unlock.
*/
export interface LocalIdentity {
uuid: string;
privateKey: string;
privateKey?: string;
publicKey: string;
name?: string;
t0_anniversaire: number;
@ -22,12 +23,14 @@ export interface RelayConfig {
/**
* Pair configuration (local device or remote).
* publicKey: remote device identity public key (hex), for ECDH encryption of pairing messages.
*/
export interface PairConfig {
uuid: string;
membres_parents_uuid: string[];
is_local: boolean;
can_sign: boolean;
publicKey?: string;
}
/**

View File

@ -1,13 +1,27 @@
import { idbGet, idbSet, idbRemove } from './indexedDbStorage';
/**
* Persistent cache for seen hashes to avoid rescanning.
* Persistent cache for seen hashes (IndexedDB-backed).
* Call init() before use.
*/
export class HashCache {
private cache: Set<string> = new Set();
private storageKey: string;
private initialized = false;
constructor(storageKey: string = 'userwallet_hash_cache') {
this.storageKey = storageKey;
this.loadFromStorage();
}
/**
* Load cache from IndexedDB. Must be called before hasSeen / markSeen / markSeenBatch.
*/
async init(): Promise<void> {
if (this.initialized) {
return;
}
await this.loadFromStorage();
this.initialized = true;
}
/**
@ -18,29 +32,28 @@ export class HashCache {
}
/**
* Mark a hash as seen.
* Mark a hash as seen (in-memory only). Use markSeenBatch to persist.
*/
markSeen(hash: string): void {
this.cache.add(hash);
this.saveToStorage();
}
/**
* Mark multiple hashes as seen.
* Mark multiple hashes as seen and persist to IndexedDB.
*/
markSeenBatch(hashes: string[]): void {
async markSeenBatch(hashes: string[]): Promise<void> {
for (const hash of hashes) {
this.cache.add(hash);
}
this.saveToStorage();
await this.saveToStorage();
}
/**
* Clear the cache.
* Clear the cache and remove from IndexedDB.
*/
clear(): void {
async clear(): Promise<void> {
this.cache.clear();
localStorage.removeItem(this.storageKey);
await idbRemove(this.storageKey);
}
/**
@ -50,44 +63,28 @@ export class HashCache {
return this.cache.size;
}
/**
* Load cache from localStorage.
*/
private loadFromStorage(): void {
try {
const stored = localStorage.getItem(this.storageKey);
private async loadFromStorage(): Promise<void> {
let stored = await idbGet(this.storageKey);
if (stored === null && typeof localStorage !== 'undefined') {
const legacy = localStorage.getItem(this.storageKey);
if (legacy !== null) {
await idbSet(this.storageKey, legacy);
localStorage.removeItem(this.storageKey);
stored = legacy;
}
}
if (stored !== null) {
const hashes = JSON.parse(stored) as string[];
this.cache = new Set(hashes);
}
} catch (error) {
console.error('Error loading hash cache:', error);
}
}
/**
* Save cache to localStorage.
*/
private saveToStorage(): void {
try {
const hashes = Array.from(this.cache);
localStorage.setItem(this.storageKey, JSON.stringify(hashes));
} catch (error) {
console.error('Error saving hash cache:', error);
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
this.pruneCache();
}
}
}
/**
* Prune cache if it gets too large (keep last 10000 entries).
*/
private pruneCache(): void {
const hashes = Array.from(this.cache);
private async saveToStorage(): Promise<void> {
let hashes = Array.from(this.cache);
if (hashes.length > 10000) {
this.cache = new Set(hashes.slice(-10000));
this.saveToStorage();
hashes = hashes.slice(-10000);
this.cache = new Set(hashes);
}
await idbSet(this.storageKey, JSON.stringify(hashes));
}
}

View File

@ -1,10 +1,18 @@
import { getStoredIdentity } from './identity';
import {
getStoredIdentity,
isProtectionEnabled,
} from './identity';
import { getUnlockedPrivateKey } from './sessionUnlockedKey';
import { idbGet, idbSet } from './indexedDbStorage';
import { getStoredRelays } from './relay';
import { getStoredPairs } from './pairing';
import { getStoredKeyPair, getStoredServices } from './storage';
import type { LocalIdentity, RelayConfig, PairConfig } from '../types/identity';
import type { KeyPair } from './crypto';
import type { ServiceConfig } from '../types/auth';
import type { PairingConfirmedRecord } from '../services/pairingConfirm';
const IDB_KEY_PAIRING_CONFIRM = 'userwallet_pairing_confirm';
const STORAGE_KEY_IDENTITY = 'userwallet_identity';
const STORAGE_KEY_RELAYS = 'userwallet_relays';
@ -15,6 +23,19 @@ const STORAGE_KEY_SERVICES = 'userwallet_services';
export const EXPORT_VERSION = '1.0';
/**
* Trigger download of export JSON. Used by DataExportImportScreen and HomeScreen (pre-delete export).
*/
export function downloadExportFile(json: string): void {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `userwallet-export-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
export interface UserWalletExport {
version: string;
exportedAt: number;
@ -22,23 +43,48 @@ export interface UserWalletExport {
relays: RelayConfig[];
pairs: PairConfig[];
hash_cache: string[];
pairing_confirm?: PairingConfirmedRecord[];
keypair?: KeyPair | null;
services?: ServiceConfig[];
}
/**
* Export all UserWallet data from localStorage as JSON string.
* Export all UserWallet data as JSON string.
* Identity, relays, pairs, keypair, services from localStorage; hash_cache from IndexedDB.
* When protection is enabled, requires unlock (session key) to include privateKey.
*/
export function exportUserWalletData(): string {
const identity = getStoredIdentity();
export async function exportUserWalletData(): Promise<string> {
let identity = getStoredIdentity();
if (
identity !== null &&
isProtectionEnabled() &&
identity.privateKey === undefined
) {
const sessionKey = getUnlockedPrivateKey();
if (sessionKey === null) {
throw new Error('Déverrouillez l\'identité pour exporter la clé privée.');
}
identity = { ...identity, privateKey: sessionKey };
}
const relays = getStoredRelays();
const pairs = getStoredPairs();
const keypair = getStoredKeyPair();
const services = getStoredServices();
const hashCacheRaw = localStorage.getItem(STORAGE_KEY_HASH_CACHE);
const hashCacheRaw = await idbGet(STORAGE_KEY_HASH_CACHE);
const hash_cache: string[] =
hashCacheRaw !== null ? (JSON.parse(hashCacheRaw) as string[]) : [];
const pairingConfirmRaw = await idbGet(IDB_KEY_PAIRING_CONFIRM);
let pairing_confirm: PairingConfirmedRecord[] = [];
if (pairingConfirmRaw !== null) {
try {
const parsed = JSON.parse(pairingConfirmRaw) as PairingConfirmedRecord[];
pairing_confirm = Array.isArray(parsed) ? parsed : [];
} catch {
pairing_confirm = [];
}
}
const data: UserWalletExport = {
version: EXPORT_VERSION,
exportedAt: Date.now(),
@ -46,6 +92,7 @@ export function exportUserWalletData(): string {
relays,
pairs,
hash_cache,
pairing_confirm,
keypair: keypair ?? null,
services,
};
@ -77,6 +124,19 @@ function isPairConfig(x: unknown): x is PairConfig {
);
}
function isPairingConfirmedRecord(x: unknown): x is PairingConfirmedRecord {
if (x === null || typeof x !== 'object') {
return false;
}
const o = x as Record<string, unknown>;
return (
typeof o.pairLocal === 'string' &&
typeof o.pairRemote === 'string' &&
typeof o.hash === 'string' &&
typeof o.version === 'number'
);
}
function isLocalIdentity(x: unknown): x is LocalIdentity {
if (x === null || typeof x !== 'object') {
return false;
@ -92,10 +152,11 @@ function isLocalIdentity(x: unknown): x is LocalIdentity {
}
/**
* Import UserWallet data from JSON string into localStorage.
* Overwrites existing data. Throws on invalid format.
* Import UserWallet data from JSON string.
* Overwrites existing data. identity, relays, pairs, keypair, services localStorage;
* hash_cache IndexedDB. Throws on invalid format.
*/
export function importUserWalletData(json: string): void {
export async function importUserWalletData(json: string): Promise<void> {
let parsed: unknown;
try {
parsed = JSON.parse(json) as unknown;
@ -130,7 +191,7 @@ export function importUserWalletData(json: string): void {
}
localStorage.setItem(STORAGE_KEY_RELAYS, JSON.stringify(o.relays));
localStorage.setItem(STORAGE_KEY_PAIRS, JSON.stringify(o.pairs));
localStorage.setItem(STORAGE_KEY_HASH_CACHE, JSON.stringify(o.hash_cache));
await idbSet(STORAGE_KEY_HASH_CACHE, JSON.stringify(o.hash_cache));
if ('keypair' in o && o.keypair !== undefined) {
if (o.keypair === null) {
@ -142,4 +203,12 @@ export function importUserWalletData(json: string): void {
if ('services' in o && o.services !== undefined && Array.isArray(o.services)) {
localStorage.setItem(STORAGE_KEY_SERVICES, JSON.stringify(o.services));
}
if (
'pairing_confirm' in o &&
o.pairing_confirm !== undefined &&
Array.isArray(o.pairing_confirm) &&
o.pairing_confirm.every(isPairingConfirmedRecord)
) {
await idbSet(IDB_KEY_PAIRING_CONFIRM, JSON.stringify(o.pairing_confirm));
}
}

View File

@ -1,8 +1,21 @@
import { generateKeyPair, publicKeyFromPrivateKey } from './crypto';
import { generateUuid } from './bip32';
import { validateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey } from '@scure/bip32';
import { bytesToHex } from '@noble/hashes/utils';
import {
encryptPrivateKey,
decryptPrivateKey,
type EncryptedPayload,
} from './keyProtection';
import { setUnlockedPrivateKey } from './sessionUnlockedKey';
import type { LocalIdentity } from '../types/identity';
const BIP32_DERIVATION_PATH = "m/44'/0'/0'/0/0";
const STORAGE_KEY_IDENTITY = 'userwallet_identity';
const STORAGE_KEY_IDENTITY_ENCRYPTED = 'userwallet_identity_encrypted';
/**
* Get stored local identity.
@ -45,28 +58,61 @@ export function createIdentity(name?: string): LocalIdentity {
}
/**
* Import identity from a raw hex private key (64 hex chars, 32 bytes).
*
* Limitation: Only raw secp256k1 private key hex is supported. Mnemonic (BIP39)
* and seed derivation are not implemented; use external tooling to derive the
* private key first, then import it here.
* Derive secp256k1 private key (hex) from BIP39 mnemonic.
* Uses path m/44'/0'/0'/0/0. Passphrase is not supported (empty string).
*/
function privateKeyFromMnemonic(mnemonic: string): string {
const seed = mnemonicToSeedSync(mnemonic, '');
const hdkey = HDKey.fromMasterSeed(seed);
const derived = hdkey.derive(BIP32_DERIVATION_PATH);
const pk = derived.privateKey;
if (pk === null) {
throw new Error('BIP32 derivation did not yield a private key');
}
return bytesToHex(pk);
}
/**
* Import identity from either:
* - Raw hex private key (64 hex chars, 32 bytes), or
* - BIP39 mnemonic (12 or 24 words, English). Derives key via BIP32 path m/44'/0'/0'/0/0.
* Passphrase is not supported.
*/
export function importIdentity(
seedOrPrivateKey: string,
name?: string,
): LocalIdentity | null {
const raw = seedOrPrivateKey.trim().toLowerCase().replace(/^0x/, '');
if (!/^[0-9a-f]{64}$/.test(raw)) {
const trimmed = seedOrPrivateKey.trim();
const rawHex = trimmed.toLowerCase().replace(/^0x/, '');
let privateKeyHex: string;
try {
if (/^[0-9a-f]{64}$/.test(rawHex)) {
privateKeyHex = rawHex;
} else {
const normalized = trimmed.replace(/\s+/g, ' ').trim().toLowerCase();
const words = normalized.split(' ');
if (
(words.length !== 12 && words.length !== 24) ||
!validateMnemonic(normalized, englishWordlist)
) {
console.error(
'Import identity: expected 64 hex chars (32-byte private key). Mnemonic/seed not supported.',
'Import identity: expected 64 hex chars or valid BIP39 mnemonic (12/24 English words).',
);
return null;
}
privateKeyHex = privateKeyFromMnemonic(normalized);
}
} catch (error) {
console.error('Error importing identity:', error);
return null;
}
try {
const publicKey = publicKeyFromPrivateKey(raw);
const publicKey = publicKeyFromPrivateKey(privateKeyHex);
const identity: LocalIdentity = {
uuid: generateUuid(),
privateKey: raw,
privateKey: privateKeyHex,
publicKey,
name,
t0_anniversaire: Date.now(),
@ -79,3 +125,74 @@ export function importIdentity(
return null;
}
}
export function isProtectionEnabled(): boolean {
return localStorage.getItem(STORAGE_KEY_IDENTITY_ENCRYPTED) !== null;
}
/**
* Enable password protection: encrypt private key, store encrypted blob, remove from identity.
*/
export async function enableProtection(password: string): Promise<void> {
const identity = getStoredIdentity();
if (identity === null || identity.privateKey === undefined) {
throw new Error('No identity or private key to protect');
}
const payload = await encryptPrivateKey(identity.privateKey, password);
localStorage.setItem(
STORAGE_KEY_IDENTITY_ENCRYPTED,
JSON.stringify(payload),
);
const { privateKey: _pk, ...meta } = identity;
storeIdentity(meta as LocalIdentity);
setUnlockedPrivateKey(null);
}
/**
* Unlock: decrypt and store private key in session.
*/
export async function unlock(password: string): Promise<void> {
const raw = localStorage.getItem(STORAGE_KEY_IDENTITY_ENCRYPTED);
if (raw === null) {
throw new Error('Protection not enabled');
}
const payload = JSON.parse(raw) as EncryptedPayload;
const pk = await decryptPrivateKey(payload, password);
setUnlockedPrivateKey(pk);
}
/**
* Lock: clear session (encrypted storage unchanged).
*/
export function lock(): void {
setUnlockedPrivateKey(null);
}
/**
* Disable protection: decrypt, store private key in identity, remove encrypted blob.
*/
export async function disableProtection(password: string): Promise<void> {
const raw = localStorage.getItem(STORAGE_KEY_IDENTITY_ENCRYPTED);
if (raw === null) {
throw new Error('Protection not enabled');
}
const payload = JSON.parse(raw) as EncryptedPayload;
const pk = await decryptPrivateKey(payload, password);
const identity = getStoredIdentity();
if (identity === null) {
throw new Error('No stored identity');
}
storeIdentity({ ...identity, privateKey: pk });
localStorage.removeItem(STORAGE_KEY_IDENTITY_ENCRYPTED);
setUnlockedPrivateKey(null);
}
/**
* Clear local identity and protected blob. Session key cleared. Pairs unchanged;
* caller should clear pairs separately if desired.
*/
export function clearLocalIdentity(): void {
localStorage.removeItem(STORAGE_KEY_IDENTITY);
localStorage.removeItem(STORAGE_KEY_IDENTITY_ENCRYPTED);
setUnlockedPrivateKey(null);
}

View File

@ -0,0 +1,70 @@
const DB_NAME = 'userwallet';
const DB_VERSION = 1;
const STORE_NAME = 'kv';
let dbInstance: IDBDatabase | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbInstance !== null) {
return Promise.resolve(dbInstance);
}
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
dbInstance = req.result;
resolve(req.result);
};
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
}
};
});
}
/**
* Get value by key. Returns null if not found.
*/
export async function idbGet(key: string): Promise<string | null> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(key);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const row = req.result as { key: string; value: string } | undefined;
resolve(row?.value ?? null);
};
});
}
/**
* Set value by key.
*/
export async function idbSet(key: string, value: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put({ key, value });
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}
/**
* Remove value by key.
*/
export async function idbRemove(key: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.delete(key);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}

View File

@ -0,0 +1,101 @@
const PBKDF2_ITERATIONS = 100_000;
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const KEY_LENGTH = 256;
export interface EncryptedPayload {
ciphertext: string;
iv: string;
salt: string;
}
/**
* Derive AES-GCM key from password using PBKDF2-HMAC-SHA256.
*/
async function deriveKey(
password: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
enc.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey'],
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt as BufferSource,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: KEY_LENGTH },
false,
['encrypt', 'decrypt'],
);
}
/**
* Encrypt private key (hex string) with password.
* Returns base64-encoded ciphertext, iv, and salt.
*/
export async function encryptPrivateKey(
privateKeyHex: string,
password: string,
): Promise<EncryptedPayload> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(password, salt);
const enc = new TextEncoder();
const plaintext = enc.encode(privateKeyHex);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext,
);
return {
ciphertext: b64Encode(new Uint8Array(ciphertext)),
iv: b64Encode(iv),
salt: b64Encode(salt),
};
}
/**
* Decrypt private key from payload using password.
* Returns hex string or throws.
*/
export async function decryptPrivateKey(
payload: EncryptedPayload,
password: string,
): Promise<string> {
const salt = b64Decode(payload.salt);
const iv = b64Decode(payload.iv);
const ciphertext = b64Decode(payload.ciphertext);
const key = await deriveKey(password, salt);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
ciphertext as BufferSource,
);
return new TextDecoder().decode(decrypted);
}
function b64Encode(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] ?? 0);
}
return btoa(binary);
}
function b64Decode(str: string): Uint8Array {
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
out[i] = bin.charCodeAt(i);
}
return out;
}

View File

@ -1,4 +1,8 @@
import { generateUuid, uuidToBip32Words, bip32WordsToUuid } from './bip32';
import {
generateUuid,
uuidToBip32Words,
bip32WordsToUuid,
} from './bip32';
import type { PairConfig } from '../types/identity';
const STORAGE_KEY_PAIRS = 'userwallet_pairs';
@ -49,10 +53,12 @@ export function createLocalPair(membresParentsUuid: string[]): {
/**
* Add a remote pair from BIP32 words.
* Optional remotePublicKey (hex) stored for ECDH encryption of pairing messages.
*/
export function addRemotePairFromWords(
words: string[],
membresParentsUuid: string[],
remotePublicKey?: string,
): PairConfig | null {
const uuid = bip32WordsToUuid(words);
if (uuid === null) {
@ -63,6 +69,9 @@ export function addRemotePairFromWords(
membres_parents_uuid: membresParentsUuid,
is_local: false,
can_sign: false,
...(remotePublicKey !== undefined && remotePublicKey !== ''
? { publicKey: remotePublicKey }
: {}),
};
const pairs = getStoredPairs();
pairs.push(pair);
@ -70,6 +79,49 @@ export function addRemotePairFromWords(
return pair;
}
/**
* Generate BIP32 words for pairing without creating or storing a pair.
* Use when showing QR/URL for second device; user adds remote pair on both
* devices from these words.
*/
export function generatePairingWords(): string[] {
const uuid = generateUuid();
return uuidToBip32Words(uuid);
}
const EXPECTED_PAIRING_WORD_COUNT = 8;
/** Secp256k1 compressed public key: 66 hex chars, 02 or 03 prefix. */
const PUBKEY_HEX_LEN = 66;
const PUBKEY_PREFIX = /^0[23]/;
export function validatePublicKeyHex(hex: string): boolean {
const trimmed = hex.trim().toLowerCase();
if (trimmed.length !== PUBKEY_HEX_LEN) {
return false;
}
if (!/^[0-9a-f]+$/.test(trimmed)) {
return false;
}
return PUBKEY_PREFIX.test(trimmed);
}
/**
* Parse and validate pairing words from user input (whitespace-separated).
* Returns word array if valid, null otherwise.
*/
export function parseAndValidatePairingWords(text: string): string[] | null {
const words = text
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (words.length !== EXPECTED_PAIRING_WORD_COUNT) {
return null;
}
const uuid = bip32WordsToUuid(words);
return uuid !== null ? words : null;
}
/**
* Check if pairing is satisfied (at least one pair available).
*/
@ -78,6 +130,31 @@ export function isPairingSatisfied(): boolean {
return pairs.length > 0;
}
/**
* True if at least one stored pair is remote (is_local === false).
*/
export function hasRemotePair(): boolean {
const pairs = getStoredPairs();
return pairs.some((p) => !p.is_local);
}
/**
* Ensure a local pair exists for setup (1st device). If none, create one.
* Returns the local pair's words for QR/URL. Idempotent.
*/
export function ensureLocalPairForSetup(): string[] {
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
if (local !== undefined) {
return uuidToBip32Words(local.uuid);
}
if (pairs.length > 0) {
return [];
}
const { words } = createLocalPair([]);
return words;
}
/**
* Get pairs for a specific member.
*/
@ -85,3 +162,24 @@ export function getPairsForMember(membreUuid: string): PairConfig[] {
const pairs = getStoredPairs();
return pairs.filter((p) => p.membres_parents_uuid.includes(membreUuid));
}
/**
* Words for the local pair (8 words, BIP32-style). Null if no local pair.
* Does not create a pair.
*/
export function getLocalPairWords(): string[] | null {
const pairs = getStoredPairs();
const local = pairs.find((p) => p.is_local);
if (local === undefined) {
return null;
}
return uuidToBip32Words(local.uuid);
}
/**
* Sync check not available; use usePairingConnected() hook for async result.
* Kept for backward compatibility where only sync check was used.
*/
export function isPairingConnected(): boolean {
return false;
}

View File

@ -0,0 +1,9 @@
let unlockedPrivateKey: string | null = null;
export function setUnlockedPrivateKey(pk: string | null): void {
unlockedPrivateKey = pk;
}
export function getUnlockedPrivateKey(): string | null {
return unlockedPrivateKey;
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
2026-01-25T23:58:25.499Z;9248
2026-01-26T08:03:05.253Z;9782