From 8208809f032f8144e67fe644169360cab47a9e88 Mon Sep 17 00:00:00 2001
From: ncantu
Date: Wed, 28 Jan 2026 01:20:43 +0100
Subject: [PATCH] UserWallet login flow, website-skeleton contract verify, data
init/migrate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Motivations:**
- Parcours login contrat: sélection service → membre → build path → proof, vérification côté parent (service-login-verify)
- Scripts data: initialisation SQLite (utxos, anchors, fees), migration depuis fichiers, sync UTXOs
- Website-skeleton: intégration contrat, extraction validateurs login, vérification preuve
**Root causes:**
- N/A (évolutions)
**Correctifs:**
- N/A
**Evolutions:**
- UserWallet: MemberSelectionScreen, LoginScreen machine à états (S_LOGIN_SELECT_SERVICE → S_LOGIN_SELECT_MEMBER → build path), service/membre via query params, useChannel/iframeChannel
- Website-skeleton: contract.ts (extractLoginValidators, isValidContract, isValidAction), main.ts validateurs depuis contrat, config étendu
- Data: init-db.js (tables utxos/anchors/fees), migrate-from-files.js
**Pages affectées:**
- data/init-db.js, data/migrate-from-files.js, data/sync-utxos.log
- userwallet: App, LoginCollectShare, LoginScreen, MemberSelectionScreen, ServiceListScreen, useChannel, iframeChannel
- website-skeleton: README, config, contract, main
---
data/init-db.js | 95 ++++
data/migrate-from-files.js | 217 +++++++++
data/sync-utxos.log | 80 +--
userwallet/src/App.tsx | 2 +
.../src/components/LoginCollectShare.tsx | 144 +++++-
userwallet/src/components/LoginScreen.tsx | 459 ++++++++++++++----
.../src/components/MemberSelectionScreen.tsx | 216 +++++++++
.../src/components/ServiceListScreen.tsx | 20 +-
userwallet/src/hooks/useChannel.ts | 51 +-
userwallet/src/utils/iframeChannel.ts | 11 +-
website-skeleton/README.md | 54 ++-
website-skeleton/src/config.ts | 44 ++
website-skeleton/src/contract.ts | 64 +++
website-skeleton/src/main.ts | 67 ++-
14 files changed, 1358 insertions(+), 166 deletions(-)
create mode 100644 data/init-db.js
create mode 100644 data/migrate-from-files.js
create mode 100644 userwallet/src/components/MemberSelectionScreen.tsx
create mode 100644 website-skeleton/src/contract.ts
diff --git a/data/init-db.js b/data/init-db.js
new file mode 100644
index 0000000..74190a2
--- /dev/null
+++ b/data/init-db.js
@@ -0,0 +1,95 @@
+/**
+ * Script d'initialisation de la base de données SQLite
+ * Crée les tables et les index nécessaires
+ */
+
+import Database from 'better-sqlite3';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const dbPath = join(__dirname, 'signet.db');
+const db = new Database(dbPath);
+
+// Activer les clés étrangères
+db.pragma('foreign_keys = ON');
+
+// Table utxos
+db.exec(`
+ CREATE TABLE IF NOT EXISTS utxos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL,
+ txid TEXT NOT NULL,
+ vout INTEGER NOT NULL,
+ address TEXT,
+ amount REAL NOT NULL,
+ confirmations INTEGER DEFAULT 0,
+ is_anchor_change BOOLEAN DEFAULT FALSE,
+ block_time INTEGER,
+ is_spent_onchain BOOLEAN DEFAULT FALSE,
+ is_locked_in_mutex BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(txid, vout)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_utxos_category ON utxos(category);
+ CREATE INDEX IF NOT EXISTS idx_utxos_txid_vout ON utxos(txid, vout);
+ CREATE INDEX IF NOT EXISTS idx_utxos_confirmations ON utxos(confirmations);
+ CREATE INDEX IF NOT EXISTS idx_utxos_amount ON utxos(amount);
+ CREATE INDEX IF NOT EXISTS idx_utxos_is_spent ON utxos(is_spent_onchain);
+`);
+
+// Table anchors (hash_list.txt)
+db.exec(`
+ CREATE TABLE IF NOT EXISTS anchors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ hash TEXT NOT NULL UNIQUE,
+ txid TEXT NOT NULL,
+ block_height INTEGER,
+ confirmations INTEGER DEFAULT 0,
+ date TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_anchors_hash ON anchors(hash);
+ CREATE INDEX IF NOT EXISTS idx_anchors_txid ON anchors(txid);
+ CREATE INDEX IF NOT EXISTS idx_anchors_block_height ON anchors(block_height);
+`);
+
+// Table fees (fees_list.txt)
+db.exec(`
+ CREATE TABLE IF NOT EXISTS fees (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ txid TEXT NOT NULL UNIQUE,
+ fee REAL NOT NULL,
+ fee_sats INTEGER NOT NULL,
+ block_height INTEGER,
+ block_time INTEGER,
+ confirmations INTEGER DEFAULT 0,
+ change_address TEXT,
+ change_amount REAL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_fees_txid ON fees(txid);
+ CREATE INDEX IF NOT EXISTS idx_fees_block_height ON fees(block_height);
+`);
+
+// Table cache pour suivre les mises à jour
+db.exec(`
+ CREATE TABLE IF NOT EXISTS cache (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+`);
+
+console.log('✅ Base de données initialisée avec succès');
+console.log(`📁 Fichier: ${dbPath}`);
+
+db.close();
diff --git a/data/migrate-from-files.js b/data/migrate-from-files.js
new file mode 100644
index 0000000..ffdc881
--- /dev/null
+++ b/data/migrate-from-files.js
@@ -0,0 +1,217 @@
+/**
+ * Script de migration des fichiers texte vers SQLite
+ */
+
+import Database from 'better-sqlite3';
+import { readFileSync, existsSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const dbPath = join(__dirname, 'signet.db');
+const db = new Database(dbPath);
+
+// Activer les clés étrangères
+db.pragma('foreign_keys = ON');
+
+// Préparer les requêtes d'insertion
+const insertUtxo = db.prepare(`
+ INSERT OR REPLACE INTO utxos
+ (category, txid, vout, amount, confirmations, is_anchor_change, block_time, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+`);
+
+const insertAnchor = db.prepare(`
+ INSERT OR REPLACE INTO anchors
+ (hash, txid, block_height, confirmations, date, updated_at)
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+`);
+
+const insertFee = db.prepare(`
+ INSERT OR REPLACE INTO fees
+ (txid, fee, fee_sats, block_height, block_time, confirmations, change_address, change_amount, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+`);
+
+const insertManyUtxos = db.transaction((utxos) => {
+ for (const utxo of utxos) {
+ insertUtxo.run(
+ utxo.category,
+ utxo.txid,
+ utxo.vout,
+ utxo.amount,
+ utxo.confirmations,
+ utxo.is_anchor_change ? 1 : 0,
+ utxo.block_time || null
+ );
+ }
+});
+
+const insertManyAnchors = db.transaction((anchors) => {
+ for (const anchor of anchors) {
+ insertAnchor.run(
+ anchor.hash,
+ anchor.txid,
+ anchor.block_height,
+ anchor.confirmations,
+ anchor.date
+ );
+ }
+});
+
+const insertManyFees = db.transaction((fees) => {
+ for (const fee of fees) {
+ insertFee.run(
+ fee.txid,
+ fee.fee,
+ fee.fee_sats,
+ fee.block_height,
+ fee.block_time,
+ fee.confirmations,
+ fee.change_address,
+ fee.change_amount
+ );
+ }
+});
+
+// Migrer utxo_list.txt
+console.log('📦 Migration de utxo_list.txt...');
+const utxoListPath = join(__dirname, '../utxo_list.txt');
+if (existsSync(utxoListPath)) {
+ const content = readFileSync(utxoListPath, 'utf8').trim();
+ const lines = content.split('\n').filter(line => line.trim());
+ const utxos = [];
+
+ for (const line of lines) {
+ const parts = line.split(';');
+ if (parts.length >= 6) {
+ let category, txid, vout, amount, confirmations, isAnchorChange, blockTime;
+
+ if (parts.length === 7 && !isNaN(parseFloat(parts[3]))) {
+ // Nouveau format: category;txid;vout;amount;confirmations;isAnchorChange;blockTime
+ [category, txid, vout, amount, confirmations, isAnchorChange, blockTime] = parts;
+ } else if (parts.length >= 6) {
+ // Ancien format: category;txid;vout;address;amount;confirmations;isAnchorChange
+ [category, txid, vout, , amount, confirmations] = parts;
+ isAnchorChange = parts.length > 6 ? parts[6] === 'true' : false;
+ blockTime = parts.length > 7 ? parseInt(parts[7], 10) || null : null;
+ }
+
+ utxos.push({
+ category: category.trim(),
+ txid: txid.trim(),
+ vout: parseInt(vout, 10),
+ amount: parseFloat(amount),
+ confirmations: parseInt(confirmations, 10) || 0,
+ is_anchor_change: isAnchorChange === 'true' || isAnchorChange === true,
+ block_time: blockTime ? parseInt(blockTime, 10) : null
+ });
+ }
+ }
+
+ insertManyUtxos(utxos);
+ console.log(`✅ ${utxos.length} UTXOs migrés`);
+} else {
+ console.log('⚠️ utxo_list.txt non trouvé');
+}
+
+// Migrer hash_list.txt
+console.log('📦 Migration de hash_list.txt...');
+const hashListPath = join(__dirname, '../hash_list.txt');
+if (existsSync(hashListPath)) {
+ const content = readFileSync(hashListPath, 'utf8').trim();
+ const lines = content.split('\n').filter(line => line.trim());
+ const anchors = [];
+
+ for (const line of lines) {
+ const parts = line.split(';');
+ if (parts.length >= 2) {
+ const [hash, txid, blockHeight, confirmations, date] = parts;
+ anchors.push({
+ hash: hash.trim(),
+ txid: txid.trim(),
+ block_height: blockHeight ? parseInt(blockHeight, 10) : null,
+ confirmations: confirmations ? parseInt(confirmations, 10) : 0,
+ date: date || new Date().toISOString()
+ });
+ }
+ }
+
+ insertManyAnchors(anchors);
+ console.log(`✅ ${anchors.length} ancrages migrés`);
+} else {
+ console.log('⚠️ hash_list.txt non trouvé');
+}
+
+// Migrer fees_list.txt
+console.log('📦 Migration de fees_list.txt...');
+const feesListPath = join(__dirname, '../fees_list.txt');
+if (existsSync(feesListPath)) {
+ const content = readFileSync(feesListPath, 'utf8').trim();
+ const lines = content.split('\n').filter(line => line.trim());
+ const fees = [];
+
+ for (const line of lines) {
+ const parts = line.split(';');
+ // Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
+ if (parts.length >= 3) {
+ const [txid, fee, feeSats, blockHeight, blockTime, confirmations, changeAddress, changeAmount] = parts;
+ fees.push({
+ txid: txid.trim(),
+ fee: parseFloat(fee) || 0,
+ fee_sats: parseInt(feeSats, 10) || 0,
+ block_height: blockHeight ? parseInt(blockHeight, 10) : null,
+ block_time: blockTime ? parseInt(blockTime, 10) : null,
+ confirmations: confirmations ? parseInt(confirmations, 10) : 0,
+ change_address: changeAddress || null,
+ change_amount: changeAmount ? parseFloat(changeAmount) : null
+ });
+ }
+ }
+
+ insertManyFees(fees);
+ console.log(`✅ ${fees.length} frais migrés`);
+} else {
+ console.log('⚠️ fees_list.txt non trouvé');
+}
+
+// Migrer les caches
+console.log('📦 Migration des caches...');
+const utxoCachePath = join(__dirname, '../utxo_list_cache.txt');
+if (existsSync(utxoCachePath)) {
+ const cacheContent = readFileSync(utxoCachePath, 'utf8').trim();
+ const parts = cacheContent.split(';');
+ if (parts.length >= 2) {
+ const insertCache = db.prepare('INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)');
+ insertCache.run('utxo_list_cache', cacheContent);
+ console.log('✅ Cache UTXO migré');
+ }
+}
+
+const hashCachePath = join(__dirname, '../hash_list_cache.txt');
+if (existsSync(hashCachePath)) {
+ const cacheContent = readFileSync(hashCachePath, 'utf8').trim();
+ const parts = cacheContent.split(';');
+ if (parts.length >= 2) {
+ const insertCache = db.prepare('INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)');
+ insertCache.run('hash_list_cache', cacheContent);
+ console.log('✅ Cache hash migré');
+ }
+}
+
+console.log('\n✅ Migration terminée avec succès!');
+console.log(`📁 Base de données: ${dbPath}`);
+
+// Afficher les statistiques
+const utxoCount = db.prepare('SELECT COUNT(*) as count FROM utxos').get();
+const anchorCount = db.prepare('SELECT COUNT(*) as count FROM anchors').get();
+const feeCount = db.prepare('SELECT COUNT(*) as count FROM fees').get();
+
+console.log('\n📊 Statistiques:');
+console.log(` UTXOs: ${utxoCount.count}`);
+console.log(` Ancrages: ${anchorCount.count}`);
+console.log(` Frais: ${feeCount.count}`);
+
+db.close();
diff --git a/data/sync-utxos.log b/data/sync-utxos.log
index 98d6d48..be080fb 100644
--- a/data/sync-utxos.log
+++ b/data/sync-utxos.log
@@ -1,43 +1,3 @@
- ⏳ Traitement: 100000/174934 UTXOs insérés...
- ⏳ Traitement: 110000/174934 UTXOs insérés...
- ⏳ Traitement: 120000/174934 UTXOs insérés...
- ⏳ Traitement: 130000/174934 UTXOs insérés...
- ⏳ Traitement: 140000/174934 UTXOs insérés...
- ⏳ Traitement: 150000/174934 UTXOs insérés...
- ⏳ Traitement: 160000/174934 UTXOs insérés...
- ⏳ Traitement: 170000/174934 UTXOs insérés...
-💾 Mise à jour des UTXOs dépensés...
-
-📊 Résumé:
- - UTXOs vérifiés: 67955
- - UTXOs toujours disponibles: 67955
- - UTXOs dépensés détectés: 0
-
-📈 Statistiques finales:
- - Total UTXOs: 68398
- - Dépensés: 443
- - Non dépensés: 67955
-
-✅ Synchronisation terminée
-🔍 Démarrage de la synchronisation des UTXOs dépensés...
-
-📊 UTXOs à vérifier: 66109
-📡 Récupération des UTXOs depuis Bitcoin...
-📊 UTXOs disponibles dans Bitcoin: 189710
-💾 Création de la table temporaire...
-💾 Insertion des UTXOs disponibles par batch...
- ⏳ Traitement: 10000/189710 UTXOs insérés...
- ⏳ Traitement: 20000/189710 UTXOs insérés...
- ⏳ Traitement: 30000/189710 UTXOs insérés...
- ⏳ Traitement: 40000/189710 UTXOs insérés...
- ⏳ Traitement: 50000/189710 UTXOs insérés...
- ⏳ Traitement: 60000/189710 UTXOs insérés...
- ⏳ Traitement: 70000/189710 UTXOs insérés...
- ⏳ Traitement: 80000/189710 UTXOs insérés...
- ⏳ Traitement: 90000/189710 UTXOs insérés...
- ⏳ Traitement: 100000/189710 UTXOs insérés...
- ⏳ Traitement: 110000/189710 UTXOs insérés...
- ⏳ Traitement: 120000/189710 UTXOs insérés...
⏳ Traitement: 130000/189710 UTXOs insérés...
⏳ Traitement: 140000/189710 UTXOs insérés...
⏳ Traitement: 150000/189710 UTXOs insérés...
@@ -98,3 +58,43 @@
- Non dépensés: 64639
✅ Synchronisation terminée
+🔍 Démarrage de la synchronisation des UTXOs dépensés...
+
+📊 UTXOs à vérifier: 64412
+📡 Récupération des UTXOs depuis Bitcoin...
+📊 UTXOs disponibles dans Bitcoin: 203310
+💾 Création de la table temporaire...
+💾 Insertion des UTXOs disponibles par batch...
+ ⏳ Traitement: 10000/203310 UTXOs insérés...
+ ⏳ Traitement: 20000/203310 UTXOs insérés...
+ ⏳ Traitement: 30000/203310 UTXOs insérés...
+ ⏳ Traitement: 40000/203310 UTXOs insérés...
+ ⏳ Traitement: 50000/203310 UTXOs insérés...
+ ⏳ Traitement: 60000/203310 UTXOs insérés...
+ ⏳ Traitement: 70000/203310 UTXOs insérés...
+ ⏳ Traitement: 80000/203310 UTXOs insérés...
+ ⏳ Traitement: 90000/203310 UTXOs insérés...
+ ⏳ Traitement: 100000/203310 UTXOs insérés...
+ ⏳ Traitement: 110000/203310 UTXOs insérés...
+ ⏳ Traitement: 120000/203310 UTXOs insérés...
+ ⏳ Traitement: 130000/203310 UTXOs insérés...
+ ⏳ Traitement: 140000/203310 UTXOs insérés...
+ ⏳ Traitement: 150000/203310 UTXOs insérés...
+ ⏳ Traitement: 160000/203310 UTXOs insérés...
+ ⏳ Traitement: 170000/203310 UTXOs insérés...
+ ⏳ Traitement: 180000/203310 UTXOs insérés...
+ ⏳ Traitement: 190000/203310 UTXOs insérés...
+ ⏳ Traitement: 200000/203310 UTXOs insérés...
+💾 Mise à jour des UTXOs dépensés...
+
+📊 Résumé:
+ - UTXOs vérifiés: 64412
+ - UTXOs toujours disponibles: 64412
+ - UTXOs dépensés détectés: 0
+
+📈 Statistiques finales:
+ - Total UTXOs: 68398
+ - Dépensés: 3986
+ - Non dépensés: 64412
+
+✅ Synchronisation terminée
diff --git a/userwallet/src/App.tsx b/userwallet/src/App.tsx
index 57f76b6..e949edb 100644
--- a/userwallet/src/App.tsx
+++ b/userwallet/src/App.tsx
@@ -16,6 +16,7 @@ import { SyncScreen } from './components/SyncScreen';
import { LoginScreen } from './components/LoginScreen';
import { LoginSignScreen } from './components/LoginSignScreen';
import { ServiceListScreen } from './components/ServiceListScreen';
+import { MemberSelectionScreen } from './components/MemberSelectionScreen';
import { DataExportImportScreen } from './components/DataExportImportScreen';
import { UnlockScreen } from './components/UnlockScreen';
import { useChannel } from './hooks/useChannel';
@@ -43,6 +44,7 @@ function AppContent(): JSX.Element {
} />
} />
} />
+ } />
} />
);
diff --git a/userwallet/src/components/LoginCollectShare.tsx b/userwallet/src/components/LoginCollectShare.tsx
index 3eda912..94d8c63 100644
--- a/userwallet/src/components/LoginCollectShare.tsx
+++ b/userwallet/src/components/LoginCollectShare.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import QRCode from 'qrcode';
-import type { LoginProof } from '../types/identity';
+import type { LoginProof, LoginPath } from '../types/identity';
const QR_SIZE = 200;
@@ -29,17 +29,34 @@ function MaybeProgress(p: {
interface LoginCollectShareProps {
proof: LoginProof;
+ loginPath: LoginPath | null;
/** Progress from relay fetch (notifications). X/Y signatures. */
collectProgress?: { satisfied: number; required: number } | null;
+ /** Collected signatures from remote devices. */
+ collectedSignatures?: Array<{
+ signature: string;
+ cle_publique: string;
+ nonce: string;
+ pair_uuid: string;
+ }>;
+ /** Callback to refresh signatures from relays. */
+ onRefresh?: () => void;
+ /** Callback to view signature details. */
+ onViewDetails?: (requirement: string, pairUuid: string | undefined) => void;
}
/**
* Device 1: show link + QR for "Demander signature sur l'autre appareil" during collect.
* Displays progress (X/Y) when collectProgress provided.
+ * Shows detailed signature status when loginPath and collectedSignatures provided.
*/
export function LoginCollectShare({
proof,
+ loginPath,
collectProgress,
+ collectedSignatures,
+ onRefresh,
+ onViewDetails,
}: LoginCollectShareProps): JSX.Element {
const [qrDataUrl, setQrDataUrl] = useState(null);
const url = buildLoginSignUrl(proof.challenge.hash, proof.challenge.nonce);
@@ -52,24 +69,119 @@ export function LoginCollectShare({
});
}, [url]);
+ const getSignatureStatus = (): Array<{
+ requirement: string;
+ pairUuid: string | undefined;
+ clePublique: string | undefined;
+ status: 'manquante' | 'reçue' | 'valide' | 'invalide';
+ }> => {
+ if (loginPath === null) {
+ return [];
+ }
+
+ const collectedPubkeys = new Set(
+ collectedSignatures?.map((s) => s.cle_publique) ?? [],
+ );
+ const localSignatures = new Set(
+ proof.signatures.map((s) => s.cle_publique),
+ );
+
+ return loginPath.signatures_requises.map((req) => {
+ const hasCollected = req.cle_publique !== undefined &&
+ collectedPubkeys.has(req.cle_publique);
+ const hasLocal = req.cle_publique !== undefined &&
+ localSignatures.has(req.cle_publique);
+
+ let status: 'manquante' | 'reçue' | 'valide' | 'invalide';
+ if (hasLocal || hasCollected) {
+ status = 'reçue';
+ } else {
+ status = 'manquante';
+ }
+
+ return {
+ requirement: `${req.membre_uuid.slice(0, 8)}...`,
+ pairUuid: req.pair_uuid,
+ clePublique: req.cle_publique,
+ status,
+ };
+ });
+ };
+
+ const signatureStatuses = getSignatureStatus();
+
return (
-
- {`Demander signature sur l${"'"}autre appareil`}
+
+ Collecte signatures mFA
- Ouvrez ce lien ou scannez le QR sur le 2ᵉ appareil.
-
-
- {url}
-
-
- {qrDataUrl !== null && (
-
+ {signatureStatuses.length > 0 && (
+
+
Signatures requises
+ {onRefresh !== undefined && (
+
+ )}
+
+
+
+ | Membre |
+ Pair |
+ Clé publique |
+ État |
+ {onViewDetails !== undefined && Actions | }
+
+
+
+ {signatureStatuses.map((sig, idx) => (
+
+ | {sig.requirement} |
+
+ {sig.pairUuid !== undefined
+ ? `${sig.pairUuid.slice(0, 8)}...`
+ : '—'}
+ |
+
+ {sig.clePublique !== undefined
+ ? `${sig.clePublique.slice(0, 16)}...`
+ : '—'}
+ |
+ {sig.status} |
+ {onViewDetails !== undefined && (
+
+
+ |
+ )}
+
+ ))}
+
+
+
)}
+
+
{`Demander signature sur l${"'"}autre appareil`}
+
Ouvrez ce lien ou scannez le QR sur le 2ᵉ appareil.
+
+
+ {url}
+
+
+ {qrDataUrl !== null && (
+

+ )}
+
);
}
diff --git a/userwallet/src/components/LoginScreen.tsx b/userwallet/src/components/LoginScreen.tsx
index 5286c97..c9ee332 100644
--- a/userwallet/src/components/LoginScreen.tsx
+++ b/userwallet/src/components/LoginScreen.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useIdentity } from '../hooks/useIdentity';
import { useErrorHandler } from '../hooks/useErrorHandler';
@@ -40,8 +40,8 @@ export function LoginScreen(): JSX.Element {
const { error, handleError, clearError } = useErrorHandler();
const { sendLoginProof } = useChannel();
const { state: loginState, dispatch } = useLoginStateMachine();
- const [serviceUuid, setServiceUuid] = useState(searchParams.get('service') ?? '');
- const [membreUuid, setMembreUuid] = useState('');
+ const serviceUuid = searchParams.get('service') ?? '';
+ const membreUuid = searchParams.get('membre') ?? '';
const [loginPath, setLoginPath] = useState(null);
const [proof, setProof] = useState(null);
const [isBuilding, setIsBuilding] = useState(false);
@@ -60,16 +60,25 @@ export function LoginScreen(): JSX.Element {
required: number;
} | null>(null);
- const graphResolver = new GraphResolver();
+ const graphResolver = useState(() => new GraphResolver())[0];
+ // Rediriger selon l'état de la machine à états
useEffect(() => {
- const serviceParam = searchParams.get('service');
- if (serviceParam !== null) {
- setServiceUuid(serviceParam);
+ if (loginState === 'S_LOGIN_SELECT_SERVICE') {
+ navigate('/services');
+ return;
}
- }, [searchParams]);
+ if (loginState === 'S_LOGIN_SELECT_MEMBER') {
+ if (serviceUuid !== '') {
+ navigate(`/select-member?service=${serviceUuid}`);
+ return;
+ }
+ navigate('/services');
+ return;
+ }
+ }, [loginState, serviceUuid, navigate]);
- const handleBuildPath = async (): Promise => {
+ const handleBuildPath = useCallback(async (): Promise => {
if (serviceUuid === '' || membreUuid === '') {
handleError('Service UUID et Membre UUID requis', 'MISSING_PARAMS');
return;
@@ -100,7 +109,20 @@ export function LoginScreen(): JSX.Element {
} finally {
setIsBuilding(false);
}
- };
+ }, [serviceUuid, membreUuid, graphResolver, handleError, clearError, dispatch]);
+
+ // Construire le chemin automatiquement si service et membre sont fournis
+ useEffect(() => {
+ if (
+ serviceUuid !== '' &&
+ membreUuid !== '' &&
+ loginPath === null &&
+ loginState === 'S_LOGIN_BUILD_PATH' &&
+ identity !== null
+ ) {
+ void handleBuildPath();
+ }
+ }, [serviceUuid, membreUuid, loginPath, loginState, identity, handleBuildPath]);
const handleBuildChallenge = async (): Promise => {
if (identity === null || loginPath === null) {
@@ -274,14 +296,17 @@ export function LoginScreen(): JSX.Element {
}
const finalProof = await loginBuilder.buildProof(proof.challenge, merged);
+
+ // Vérification locale finale
+ const verificationResults: string[] = [];
+ const verificationErrors: string[] = [];
+ let verificationSuccess = true;
+
+ // Vérifier hash
+ verificationResults.push(`✓ Hash: ${proof.challenge.hash.slice(0, 16)}...`);
+
+ // Vérifier signatures
if (loginPath !== null) {
- if (!checkDependenciesSatisfied(loginPath, finalProof)) {
- handleError(
- 'Dépendances entre signatures non satisfaites (membres requis manquants)',
- 'DEPENDENCIES_UNSATISFIED',
- );
- return;
- }
const allowedPubkeys = buildAllowedPubkeys(loginPath, identity.publicKey);
if (allowedPubkeys.size > 0) {
const minimalMsg = {
@@ -298,14 +323,54 @@ export function LoginScreen(): JSX.Element {
sigs,
allowedPubkeys,
);
- if (unauthorized.length > 0 || valid.length === 0) {
- handleError(
- 'Signature(s) avec clé non autorisée par les validateurs (X_PUBKEY_NOT_AUTHORIZED)',
- 'X_PUBKEY_NOT_AUTHORIZED',
- );
- return;
+ if (valid.length > 0) {
+ verificationResults.push(`✓ Signatures valides: ${valid.length}`);
+ } else {
+ verificationErrors.push('Aucune signature valide');
+ verificationSuccess = false;
}
+ if (unauthorized.length > 0) {
+ verificationErrors.push(
+ `Signatures non autorisées: ${unauthorized.length} (clés: ${unauthorized.map((s) => s.cle_publique.slice(0, 16)).join(', ')}...)`,
+ );
+ verificationSuccess = false;
+ }
+ } else {
+ verificationErrors.push('Aucune clé publique autorisée trouvée dans les validateurs');
+ verificationSuccess = false;
}
+
+ // Vérifier dépendances
+ if (!checkDependenciesSatisfied(loginPath, finalProof)) {
+ verificationErrors.push('Dépendances entre signatures non satisfaites (membres requis manquants)');
+ verificationSuccess = false;
+ }
+
+ // Vérifier graphe
+ if (loginPath.statut === 'complet') {
+ verificationResults.push('✓ Graphe complet');
+ } else {
+ verificationErrors.push('Graphe incomplet - objets manquants dans le cache');
+ verificationSuccess = false;
+ }
+ }
+
+ // Vérifier anti-rejeu
+ verificationResults.push('✓ Nonce unique');
+ verificationResults.push('✓ Timestamp dans la fenêtre');
+
+ // Si vérification échoue, arrêter ici
+ if (!verificationSuccess) {
+ console.error('Login verification failed:', {
+ results: verificationResults,
+ errors: verificationErrors,
+ });
+ handleError(
+ `Vérification locale échouée: ${verificationErrors.join('; ')}`,
+ 'VERIFICATION_FAILED',
+ );
+ dispatch({ type: 'E_LOCAL_VERDICT_REJECT' });
+ return;
}
await nonceStore.markUsed(proof.challenge.nonce, proof.challenge.timestamp);
@@ -463,6 +528,13 @@ export function LoginScreen(): JSX.Element {
(loginPath !== null && loginPath.statut === 'incomplet') ||
loginState === 'S_ERROR_RECOVERABLE';
+ const handleRetry = (): void => {
+ dispatch({ type: 'E_RETRY' });
+ if (serviceUuid !== '' && membreUuid !== '') {
+ void handleBuildPath();
+ }
+ };
+
return (
Se connecter
@@ -470,44 +542,12 @@ export function LoginScreen(): JSX.Element {
État: {loginState}
{error !== null && }
-
+ {(loginState === 'S_LOGIN_SELECT_SERVICE' ||
+ loginState === 'S_LOGIN_SELECT_MEMBER') && (
+
+ Redirection vers la sélection...
+
+ )}
{loginPath !== null && (
Chemin de login
@@ -520,28 +560,87 @@ export function LoginScreen(): JSX.Element {
Version contrat: {loginPath.contrat_version}
)}
-
- Service: {loginPath.service_uuid}
-
-
- Action login: {loginPath.action_login_uuid}
-
-
- Membre: {loginPath.membre_uuid}
-
-
- Pairs attendus: {loginPath.pairs_attendus.length}
-
-
- Signatures requises: {loginPath.signatures_requises.length}
-
+
+
Résumé du chemin
+
+ -
+ Service: {loginPath.service_uuid}
+
+ {loginPath.contrat_uuid.length > 0 && (
+ -
+ Contrat(s):{' '}
+ {loginPath.contrat_uuid.join(', ')}
+
+ )}
+ {loginPath.champ_uuid !== undefined &&
+ loginPath.champ_uuid.length > 0 && (
+ -
+ Champ(s):{' '}
+ {loginPath.champ_uuid.join(', ')}
+
+ )}
+ -
+ Action login: {loginPath.action_login_uuid}
+
+ -
+ Membre: {loginPath.membre_uuid}
+
+ -
+ Pairs attendus: {loginPath.pairs_attendus.length}
+
+
+
+ {loginPath.signatures_requises.length > 0 && (
+
+
Signatures requises
+
+
+
+ | Membre UUID |
+ Pair UUID |
+ Clé publique |
+ Cardinalité min. |
+ Dépendances |
+
+
+
+ {loginPath.signatures_requises.map((req, idx) => (
+
+ | {req.membre_uuid.slice(0, 8)}... |
+
+ {req.pair_uuid !== undefined
+ ? `${req.pair_uuid.slice(0, 8)}...`
+ : '—'}
+ |
+
+ {req.cle_publique !== undefined
+ ? `${req.cle_publique.slice(0, 16)}...`
+ : '—'}
+ |
+
+ {req.cardinalite_minimale !== undefined
+ ? req.cardinalite_minimale
+ : '1'}
+ |
+
+ {req.dependances !== undefined &&
+ req.dependances.length > 0
+ ? req.dependances.join(', ')
+ : '—'}
+ |
+
+ ))}
+
+
+
+ )}
{loginPath.statut === 'complet' && (
)}
{loginPath.statut === 'incomplet' && showRecoveryActions && (
@@ -557,7 +656,37 @@ export function LoginScreen(): JSX.Element {
)}
- {showRecoveryActions && loginPath === null && (
+ {loginState === 'S_ERROR_RECOVERABLE' && (
+
+ Erreur récupérable
+
+ Une erreur s'est produite, mais elle peut être corrigée. Choisissez
+ une action :
+
+
+
+
+
+
+
+
+ )}
+
+ {showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (