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:
parent
08eb90ed6b
commit
5689693507
52
features/userwallet-global-action-bar-url-params.md
Normal file
52
features/userwallet-global-action-bar-url-params.md
Normal 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
|
||||
46
features/userwallet-import-bip39.md
Normal file
46
features/userwallet-import-bip39.md
Normal file
@ -0,0 +1,46 @@
|
||||
# UserWallet – Import BIP39 mnemonic
|
||||
|
||||
**Author:** Équipe 4NK
|
||||
**Date:** 2026-01-26
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre l’import d’une identité à partir d’une 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 l’entrée est 64 hex → comportement actuel (clé brute). Sinon, normalisation (espaces, lowercase), vérification 12/24 mots et `validateMnemonic`, puis `privateKeyFromMnemonic`. Création de l’identité 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 l’accessibilité.
|
||||
|
||||
### 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 d’analyse
|
||||
|
||||
- Importer une phrase BIP39 valide (12 ou 24 mots) et vérifier que l’identité 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 qu’aucune régression.
|
||||
- Entrée invalide (mots incorrects, mauvais nombre) : message d’erreur, pas d’identité créée.
|
||||
55
features/userwallet-indexeddb-hash-cache.md
Normal file
55
features/userwallet-indexeddb-hash-cache.md
Normal 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 n’a pas `userwallet_hash_cache` et que localStorage l’a, 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 d’analyse
|
||||
|
||||
- Effectuer une sync : vérifier qu’aucune 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`.
|
||||
60
features/userwallet-key-protection.md
Normal file
60
features/userwallet-key-protection.md
Normal 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 d’analyse
|
||||
|
||||
- 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é.
|
||||
58
features/userwallet-pairing-qr-url-display.md
Normal file
58
features/userwallet-pairing-qr-url-display.md
Normal 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 l’URL) : **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 l’URL 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 l’URL) → 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 d’analyse
|
||||
|
||||
- Accueil avec pairing non satisfait : bloc avec mots du 1ᵉʳ, QR, URL, formulaire « Mots du 2ᵉ appareil ».
|
||||
- Ouvrir l’URL (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 d’erreur ou succès.
|
||||
- Accessibilité (ARIA, contraste, clavier) des formulaires et des liens.
|
||||
2667
fees_list.txt
Normal file
2667
fees_list.txt
Normal file
File diff suppressed because it is too large
Load Diff
2218
hash_list.txt
2218
hash_list.txt
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
||||
2026-01-25T23:53:35.210Z;9245;000000057a9ba10877ec7e33c55ab124354dd4cd693992d115068dabdf868264
|
||||
2026-01-26T07:56:10.377Z;9780;0000000cdf43bdbf9349b94f71389cc9d423557a67822f98ab790db8637e95f6
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 à l’arrê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 l’export) 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 n’est 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 l’identité 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).
|
||||
|
||||
@ -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.
|
||||
|
||||
52
userwallet/features/userwallet-pairing-connecte.md
Normal file
52
userwallet/features/userwallet-pairing-connecte.md
Normal 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 l’autre 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"** qu’il 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 l’ajoute 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é"** qu’il 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 l’existant tant que le protocole n’est pas implémenté.
|
||||
|
||||
## Modalités d’analyse
|
||||
|
||||
- Vérifier les logs (console) pour les pairs et contrats.
|
||||
- Tester le parcours "Associer le pair" sur les deux devices puis vérifier l’affichage "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 l’export, restauré à l’import ; suppression (« Supprimer ») vide aussi `userwallet_pairing_confirm`.
|
||||
366
userwallet/package-lock.json
generated
366
userwallet/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'accueil
|
||||
|
||||
@ -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' }}>
|
||||
|
||||
198
userwallet/src/components/GlobalActionBar.tsx
Normal file
198
userwallet/src/components/GlobalActionBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'/0'/0'/0/0.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="name">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 <></>;
|
||||
}
|
||||
|
||||
287
userwallet/src/components/PairingDisplayScreen.tsx
Normal file
287
userwallet/src/components/PairingDisplayScreen.tsx
Normal 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 l’URL.');
|
||||
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 été 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'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>
|
||||
);
|
||||
}
|
||||
213
userwallet/src/components/PairingSetupBlock.tsx
Normal file
213
userwallet/src/components/PairingSetupBlock.tsx
Normal 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 l’URL) sur le 2ᵉ appareil pour accéder à la
|
||||
saisie des mots du 1ᵉʳ. Saisissez ci‑dessous 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'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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
63
userwallet/src/components/UnlockScreen.tsx
Normal file
63
userwallet/src/components/UnlockScreen.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
32
userwallet/src/contexts/PairingWordsContext.tsx
Normal file
32
userwallet/src/contexts/PairingWordsContext.tsx
Normal 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);
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
53
userwallet/src/hooks/usePairingConnected.ts
Normal file
53
userwallet/src/hooks/usePairingConnected.ts
Normal 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 };
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
594
userwallet/src/services/pairingConfirm.ts
Normal file
594
userwallet/src/services/pairingConfirm.ts
Normal 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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
70
userwallet/src/utils/indexedDbStorage.ts
Normal file
70
userwallet/src/utils/indexedDbStorage.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
101
userwallet/src/utils/keyProtection.ts
Normal file
101
userwallet/src/utils/keyProtection.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
9
userwallet/src/utils/sessionUnlockedKey.ts
Normal file
9
userwallet/src/utils/sessionUnlockedKey.ts
Normal 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;
|
||||
}
|
||||
93176
utxo_list.txt
93176
utxo_list.txt
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
||||
2026-01-25T23:58:25.499Z;9248
|
||||
2026-01-26T08:03:05.253Z;9782
|
||||
Loading…
x
Reference in New Issue
Block a user