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 && ( - QR code : lien pour signer le login sur le 2e appareil + {signatureStatuses.length > 0 && ( +
+

Signatures requises

+ {onRefresh !== undefined && ( + + )} + + + + + + + + {onViewDetails !== undefined && } + + + + {signatureStatuses.map((sig, idx) => ( + + + + + + {onViewDetails !== undefined && ( + + )} + + ))} + +
MembrePairClé publiqueÉtatActions
{sig.requirement} + {sig.pairUuid !== undefined + ? `${sig.pairUuid.slice(0, 8)}...` + : '—'} + + {sig.clePublique !== undefined + ? `${sig.clePublique.slice(0, 16)}...` + : '—'} + {sig.status} + +
+
)} +
+

{`Demander signature sur l${"'"}autre appareil`}

+

Ouvrez ce lien ou scannez le QR sur le 2ᵉ appareil.

+

+ + {url} + +

+ {qrDataUrl !== null && ( + QR code : lien pour signer le login sur le 2e appareil + )} +
); } 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 && } -
-

Sélection du service

-
- -
-
- -
- -
+ {(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

+ + + + + + + + + + + + {loginPath.signatures_requises.map((req, idx) => ( + + + + + + + + ))} + +
Membre UUIDPair UUIDClé publiqueCardinalité min.Dépendances
{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' && (
)} + {loginState === 'S_LOGIN_SUCCESS' && ( +
+

Login réussi

+

La preuve de login a été acceptée et la session est ouverte.

+
+ +
+
+ )} + + {loginState === 'S_LOGIN_FAILURE' && ( +
+

Login échoué

+

La vérification locale a échoué. Diagnostics :

+ {error !== null && ( +
+

+ Erreur: {error.message} +

+ {error.code !== undefined && ( +

+ Code: {error.code} +

+ )} +
+ )} +
+

Actions possibles :

+
    +
  • + Signatures manquantes : Vérifiez que tous les pairs + requis ont signé. Utilisez "Rafraîchir" dans la collecte de + signatures. +
  • +
  • + Objets manquants : Synchronisez les données pour + récupérer les contrats, membres, pairs manquants. +
  • +
  • + Anti-rejeu : Le nonce a peut-être été réutilisé. Un + nouveau login générera un nouveau nonce. +
  • +
  • + Clés non autorisées : Vérifiez que les clés publiques + des signatures correspondent aux validateurs du contrat. +
  • +
+
+
+ + + +
+
+ )} +
diff --git a/userwallet/src/components/MemberSelectionScreen.tsx b/userwallet/src/components/MemberSelectionScreen.tsx new file mode 100644 index 0000000..18e3943 --- /dev/null +++ b/userwallet/src/components/MemberSelectionScreen.tsx @@ -0,0 +1,216 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { GraphResolver } from '../services/graphResolver'; +import { useIdentity } from '../hooks/useIdentity'; +import { useErrorHandler } from '../hooks/useErrorHandler'; +import { useLoginStateMachine } from '../hooks/useLoginStateMachine'; +import { ErrorDisplay } from './ErrorDisplay'; +import { getPairsForMember } from '../utils/pairing'; +import type { Membre, Action } from '../types/contract'; +import type { PairConfig } from '../types/identity'; + +interface MemberWithDetails { + membre: Membre; + actionsLogin: Action[]; + pairs: PairConfig[]; + hasValidLoginPath: boolean; +} + +export function MemberSelectionScreen(): JSX.Element { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { identity } = useIdentity(); + const { error, handleError, clearError } = useErrorHandler(); + const { state: loginState, dispatch } = useLoginStateMachine(); + const [members, setMembers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const serviceUuid = searchParams.get('service') ?? ''; + + useEffect(() => { + if (serviceUuid === '') { + handleError('Service UUID requis', 'MISSING_SERVICE'); + return; + } + loadMembers(); + }, [serviceUuid]); + + const loadMembers = async (): Promise => { + if (identity === null || serviceUuid === '') { + return; + } + + setIsLoading(true); + clearError(); + try { + const graphResolver = new GraphResolver(); + const allMembres = graphResolver.getMembres(); + const allActions = graphResolver.getActions(); + + // Filter membres that belong to this service + const serviceMembres = allMembres.filter((m) => + m.datajson.services_uuid.includes(serviceUuid), + ); + + // Find login actions + const loginActions = allActions.filter((action) => { + const typesNames = action.types.types_names_chiffres ?? ''; + return typesNames.includes('login'); + }); + + const membersWithDetails: MemberWithDetails[] = []; + + for (const membre of serviceMembres) { + // Find login actions for this member + const memberLoginActions = loginActions.filter((action) => + membre.actions_parents_uuid.includes(action.uuid), + ); + + // Get pairs for this member + const pairs = getPairsForMember(membre.uuid); + + // Check if login path is valid + const path = graphResolver.resolveLoginPath(serviceUuid, membre.uuid); + const hasValidLoginPath = path?.statut === 'complet'; + + membersWithDetails.push({ + membre, + actionsLogin: memberLoginActions, + pairs, + hasValidLoginPath, + }); + } + + if (membersWithDetails.length === 0) { + handleError( + 'Aucun membre avec action login trouvé pour ce service', + 'NO_MEMBERS_WITH_LOGIN', + ); + } + + setMembers(membersWithDetails); + } catch (err) { + handleError(err, 'Erreur lors du chargement des membres'); + } finally { + setIsLoading(false); + } + }; + + const handleSelectMember = (membreUuid: string): void => { + if (loginState === 'S_LOGIN_SELECT_MEMBER') { + dispatch({ type: 'E_SELECT_MEMBER', membreUuid }); + } + navigate(`/login?service=${serviceUuid}&membre=${membreUuid}`); + }; + + const handleBack = (): void => { + if (loginState === 'S_LOGIN_SELECT_MEMBER') { + dispatch({ type: 'E_BACK' }); + } + navigate('/services'); + }; + + if (serviceUuid === '') { + return ( +
+

Sélection du membre

+

Service UUID requis

+ +
+ ); + } + + return ( +
+

Sélection du membre

+

+ Service: {serviceUuid} +

+ {error !== null && } +
+

Membres disponibles

+ {isLoading ? ( +

Chargement...

+ ) : members.length === 0 ? ( +

+ Aucun membre avec action login trouvé pour ce service. Synchronisez + d'abord les données. +

+ ) : ( +
    + {members.map((memberDetail) => { + const { membre, actionsLogin, pairs, hasValidLoginPath } = + memberDetail; + return ( +
  • +
    +

    + {membre.datajson.label ?? `Membre ${membre.uuid.slice(0, 8)}`} +

    +

    + UUID: {membre.uuid} +

    + {membre.datajson.description_courte !== undefined && ( +

    {membre.datajson.description_courte}

    + )} +

    + Actions login: {actionsLogin.length} +

    + {actionsLogin.length > 0 && ( +
      + {actionsLogin.map((action) => ( +
    • + Action: {action.uuid.slice(0, 8)}... +
    • + ))} +
    + )} +

    + Pairs associés: {pairs.length} +

    + {pairs.length > 0 && ( +
      + {pairs.map((pair) => ( +
    • + {pair.is_local ? 'Local' : 'Distant'} -{' '} + {pair.can_sign ? 'Peut signer' : 'Ne peut pas signer'} +
    • + ))} +
    + )} +

    + Chemin login:{' '} + {hasValidLoginPath ? 'Complet et valide' : 'Incomplet'} +

    + +
    +
  • + ); + })} +
+ )} +
+
+ + +
+
+ ); +} diff --git a/userwallet/src/components/ServiceListScreen.tsx b/userwallet/src/components/ServiceListScreen.tsx index bef2c99..ff1dbe7 100644 --- a/userwallet/src/components/ServiceListScreen.tsx +++ b/userwallet/src/components/ServiceListScreen.tsx @@ -1,20 +1,33 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { GraphResolver } from '../services/graphResolver'; import { SyncService } from '../services/syncService'; import { useIdentity } from '../hooks/useIdentity'; import { useErrorHandler } from '../hooks/useErrorHandler'; +import { useLoginStateMachine } from '../hooks/useLoginStateMachine'; import { ErrorDisplay } from './ErrorDisplay'; import { getStoredRelays } from '../utils/relay'; import type { ServiceStatus } from '../types/identity'; export function ServiceListScreen(): JSX.Element { const navigate = useNavigate(); + const location = useLocation(); const { identity } = useIdentity(); const { error, handleError, clearError } = useErrorHandler(); + const { state: loginState, dispatch } = useLoginStateMachine(); const [services, setServices] = useState([]); const [isLoading, setIsLoading] = useState(false); + // Si on vient du flux de login, s'assurer qu'on est dans le bon état + useEffect(() => { + if (location.pathname === '/services' && loginState === 'S_LOGIN_SELECT_SERVICE') { + // On est déjà dans le bon état + } else if (location.pathname === '/services' && loginState.startsWith('S_LOGIN_')) { + // Forcer l'état si on vient du flux login + dispatch({ type: 'E_BACK' }); + } + }, [location.pathname, loginState, dispatch]); + useEffect(() => { loadServices(); }, []); @@ -76,7 +89,10 @@ export function ServiceListScreen(): JSX.Element { }; const handleSelectService = (serviceUuid: string): void => { - navigate(`/login?service=${serviceUuid}`); + if (loginState === 'S_LOGIN_SELECT_SERVICE') { + dispatch({ type: 'E_SELECT_SERVICE', serviceUuid }); + } + navigate(`/select-member?service=${serviceUuid}`); }; return ( diff --git a/userwallet/src/hooks/useChannel.ts b/userwallet/src/hooks/useChannel.ts index 1963f4c..f8877d1 100644 --- a/userwallet/src/hooks/useChannel.ts +++ b/userwallet/src/hooks/useChannel.ts @@ -1,17 +1,22 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useState } from 'react'; import { listenToChannel, sendToChannel, isInIframe, type ChannelMessage, type AuthRequestMessage, + type ContractMessage, } from '../utils/iframeChannel'; import { useIdentity } from './useIdentity'; import { signMessage, generateChallenge } from '../utils/crypto'; +import { GraphResolver } from '../services/graphResolver'; +import { updateGraphFromMessage } from '../services/syncUpdateGraph'; import type { LoginProof } from '../types/identity'; +import type { Contrat, Action } from '../types/contract'; export function useChannel() { const { identity } = useIdentity(); + const graphResolver = useState(() => new GraphResolver())[0]; const handleAuthRequest = useCallback( (_message: AuthRequestMessage): void => { @@ -44,6 +49,46 @@ export function useChannel() { [identity], ); + const handleContract = useCallback( + (message: ContractMessage): void => { + const payload = message.payload; + if (payload?.contrat !== undefined) { + // Valider et ajouter le contrat principal + try { + const contrat = payload.contrat as Contrat; + updateGraphFromMessage(contrat, graphResolver); + } catch (err) { + console.error('Error processing contract from channel:', err); + } + } + + // Ajouter les contrats fils + if (Array.isArray(payload?.contrats_fils)) { + for (const cf of payload.contrats_fils) { + try { + const contrat = cf as Contrat; + updateGraphFromMessage(contrat, graphResolver); + } catch (err) { + console.error('Error processing child contract from channel:', err); + } + } + } + + // Ajouter les actions + if (Array.isArray(payload?.actions)) { + for (const a of payload.actions) { + try { + const action = a as Action; + updateGraphFromMessage(action, graphResolver); + } catch (err) { + console.error('Error processing action from channel:', err); + } + } + } + }, + [graphResolver], + ); + const sendLoginProof = useCallback((proof: LoginProof): void => { sendToChannel({ type: 'login-proof', @@ -59,11 +104,13 @@ export function useChannel() { const cleanup = listenToChannel((message: ChannelMessage) => { if (message.type === 'auth-request') { handleAuthRequest(message as AuthRequestMessage); + } else if (message.type === 'contract') { + handleContract(message as ContractMessage); } }); return cleanup; - }, [handleAuthRequest]); + }, [handleAuthRequest, handleContract]); return { sendLoginProof, diff --git a/userwallet/src/utils/iframeChannel.ts b/userwallet/src/utils/iframeChannel.ts index 0b584c3..7ee136e 100644 --- a/userwallet/src/utils/iframeChannel.ts +++ b/userwallet/src/utils/iframeChannel.ts @@ -5,7 +5,7 @@ import type { LoginProof } from '../types/identity'; * Messages pour la communication iframe avec Channel Messages. */ export interface ChannelMessage { - type: 'auth-request' | 'auth-response' | 'login-proof' | 'service-status' | 'error'; + type: 'auth-request' | 'auth-response' | 'login-proof' | 'service-status' | 'error' | 'contract'; payload?: unknown; } @@ -45,6 +45,15 @@ export interface ErrorMessage extends ChannelMessage { }; } +export interface ContractMessage extends ChannelMessage { + type: 'contract'; + payload: { + contrat?: unknown; + contrats_fils?: unknown[]; + actions?: unknown[]; + }; +} + /** * Send message to parent window (Channel Messages). */ diff --git a/website-skeleton/README.md b/website-skeleton/README.md index 32c02cd..2877b71 100644 --- a/website-skeleton/README.md +++ b/website-skeleton/README.md @@ -1,11 +1,11 @@ # website-skeleton -Squelette d’un site qui intègre UserWallet en iframe : écoute des messages `postMessage` (auth-request, login-proof, error), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé). +Squelette d'un site qui intègre UserWallet en iframe : écoute des messages `postMessage` (auth-request, login-proof, error, contract), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé). ## Prérequis -- **service-login-verify** : `npm run build` dans `../service-login-verify` avant d’installer ou builder le skeleton. -- **UserWallet** : à servir sur l’URL configurée (voir ci‑dessous) pour que l’iframe fonctionne. +- **service-login-verify** : `npm run build` dans `../service-login-verify` avant d'installer ou builder le skeleton. +- **UserWallet** : à servir sur l'URL configurée (voir ci‑dessous) pour que l'iframe fonctionne. ## Installation @@ -21,26 +21,56 @@ npm run build npm run dev ``` -Ouvre par défaut sur `http://localhost:3024`. L’iframe pointe vers UserWallet (`USERWALLET_ORIGIN`). +Ouvre par défaut sur `http://localhost:3024`. L'iframe pointe vers UserWallet (`USERWALLET_ORIGIN`). ## Configuration -- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d’environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`). -- **Validateurs** : `DEFAULT_VALIDATEURS` dans `src/config.ts` est un placeholder. Remplacez‑le par les validateurs de l’action login de votre contrat (ou chargez‑les depuis votre API / contrat fourni par channel). +- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d'environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`). +- **Validateurs** : `DEFAULT_VALIDATEURS` dans `src/config.ts` est un placeholder. Le skeleton charge dynamiquement les validateurs depuis les contrats reçus via channel messages (type `contract`). Si aucun contrat n'est reçu, les validateurs par défaut sont utilisés. + +### Chargement dynamique des contrats + +Le skeleton écoute les messages `postMessage` de type `contract` pour recevoir le contrat et mettre à jour les validateurs dynamiquement : + +```javascript +// Exemple d'envoi de contrat depuis le parent +window.postMessage({ + type: 'contract', + payload: { + contrat: { + uuid: '...', + validateurs: { membres_du_role: [...] }, + datajson: { types_names_chiffres: 'contrat' } + }, + contrats_fils: [...], // Optionnel + actions: [...] // Optionnel, pour extraire l'action login + } +}, '*'); +``` + +Le skeleton : +1. Reçoit le message `contract` +2. Extrait les validateurs de l'action login (recherche dans `actions` pour un type contenant "login") +3. Met à jour les `allowedPubkeys` utilisés pour la vérification +4. Affiche un statut de confirmation + +Si aucun contrat n'est reçu, les `DEFAULT_VALIDATEURS` sont utilisés comme fallback. ## Utilisation -1. Lancer UserWallet (dev ou déployé) sur l’URL configurée. +1. Lancer UserWallet (dev ou déployé) sur l'URL configurée. 2. Lancer le skeleton (`npm run dev` ou servir `dist/`). -3. Ouvrir la page du skeleton : l’iframe affiche UserWallet. -4. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l’iframe. -5. **Login** : depuis l’iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ». +3. Ouvrir la page du skeleton : l'iframe affiche UserWallet. +4. **Envoyer contrat (optionnel)** : envoyer un message `contract` avec le contrat et ses actions pour mettre à jour les validateurs. +5. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l'iframe. +6. **Login** : depuis l'iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ». ## Structure - `index.html` : page avec iframe, zone de statut, bouton auth. -- `src/main.ts` : chargement de l’iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut. -- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS`. +- `src/main.ts` : chargement de l'iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut, gestion des messages `contract`. +- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS`, types `Contrat` et `Action`. +- `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`). ## Déploiement diff --git a/website-skeleton/src/config.ts b/website-skeleton/src/config.ts index 8099c2b..75e0b99 100644 --- a/website-skeleton/src/config.ts +++ b/website-skeleton/src/config.ts @@ -23,3 +23,47 @@ export const DEFAULT_VALIDATEURS = { }, ], }; + +/** + * Contract structure (matches userwallet types). + */ +export interface Contrat { + uuid: string; + validateurs: { + membres_du_role: Array<{ + membre_uuid: string; + signatures_obligatoires: Array<{ + membre_uuid: string; + cle_publique?: string; + cardinalite_minimale?: number; + dependances?: string[]; + }>; + }>; + }; + datajson?: { + types_names_chiffres?: string; + [key: string]: unknown; + }; +} + +/** + * Action structure (matches userwallet types). + */ +export interface Action { + uuid: string; + validateurs_action: { + membres_du_role: Array<{ + membre_uuid: string; + signatures_obligatoires: Array<{ + membre_uuid: string; + cle_publique?: string; + cardinalite_minimale?: number; + dependances?: string[]; + }>; + }>; + }; + datajson?: { + types_names_chiffres?: string; + [key: string]: unknown; + }; +} diff --git a/website-skeleton/src/contract.ts b/website-skeleton/src/contract.ts new file mode 100644 index 0000000..59766b1 --- /dev/null +++ b/website-skeleton/src/contract.ts @@ -0,0 +1,64 @@ +import type { Validateurs } from 'service-login-verify'; +import type { Contrat, Action } from './config.js'; + +/** + * Extract login action validators from contract and child contracts. + * Searches for action with types_names_chiffres containing "login". + */ +export function extractLoginValidators( + contrat: Contrat, + _contratsFils: Contrat[] = [], + actions: Action[] = [], +): Validateurs | null { + // Search for login action in provided actions + const loginAction = actions.find((action) => { + const typesNames = action.datajson?.types_names_chiffres ?? ''; + return typesNames.includes('login'); + }); + + if (loginAction?.validateurs_action !== undefined) { + return loginAction.validateurs_action; + } + + // If no action provided, return contract validators as fallback + // (assuming contract validators are for login action) + if (contrat.validateurs !== undefined) { + return contrat.validateurs; + } + + return null; +} + +/** + * Check if a contract structure is valid. + */ +export function isValidContract(contrat: unknown): contrat is Contrat { + if (typeof contrat !== 'object' || contrat === null) { + return false; + } + const c = contrat as Record; + return ( + typeof c.uuid === 'string' && + typeof c.validateurs === 'object' && + c.validateurs !== null && + Array.isArray((c.validateurs as { membres_du_role?: unknown }).membres_du_role) + ); +} + +/** + * Check if an action structure is valid. + */ +export function isValidAction(action: unknown): action is Action { + if (typeof action !== 'object' || action === null) { + return false; + } + const a = action as Record; + return ( + typeof a.uuid === 'string' && + typeof a.validateurs_action === 'object' && + a.validateurs_action !== null && + Array.isArray( + (a.validateurs_action as { membres_du_role?: unknown }).membres_du_role, + ) + ); +} diff --git a/website-skeleton/src/main.ts b/website-skeleton/src/main.ts index 6204e6b..b9fcc16 100644 --- a/website-skeleton/src/main.ts +++ b/website-skeleton/src/main.ts @@ -5,15 +5,20 @@ import { } from 'service-login-verify'; import type { LoginProof, Validateurs } from 'service-login-verify'; import { USERWALLET_ORIGIN, DEFAULT_VALIDATEURS } from './config.js'; +import { + extractLoginValidators, + isValidContract, + isValidAction, +} from './contract.js'; +import type { Contrat, Action } from './config.js'; const iframe = document.getElementById('userwallet') as HTMLIFrameElement; const statusEl = document.getElementById('status') as HTMLParagraphElement; const btnAuth = document.getElementById('btn-auth') as HTMLButtonElement; const nonceCache = new NonceCache(3600000); -const allowedPubkeys = buildAllowedPubkeysFromValidateurs( - DEFAULT_VALIDATEURS as Validateurs, -); +let currentValidateurs: Validateurs = DEFAULT_VALIDATEURS as Validateurs; +let allowedPubkeys = buildAllowedPubkeysFromValidateurs(currentValidateurs); function setStatus(text: string, kind: 'pending' | 'accepted' | 'rejected'): void { statusEl.textContent = text; @@ -32,12 +37,63 @@ function sendAuthRequest(): void { setStatus('Auth demandé (auth-request envoyé).', 'pending'); } +/** + * Update validators from contract and rebuild allowed pubkeys. + */ +function updateValidatorsFromContract( + contrat: Contrat, + contratsFils: Contrat[] = [], + actions: Action[] = [], +): void { + const validateurs = extractLoginValidators(contrat, contratsFils, actions); + if (validateurs !== null) { + currentValidateurs = validateurs; + allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs); + setStatus('Contrat reçu et validateurs mis à jour.', 'pending'); + } else { + setStatus( + 'Contrat reçu mais validateurs login introuvables. Utilisation des validateurs par défaut.', + 'pending', + ); + } +} + function handleMessage(event: MessageEvent): void { const d = event.data; if (d?.type === undefined) { return; } + if (d.type === 'contract') { + const payload = d.payload as { + contrat?: unknown; + contrats_fils?: unknown[]; + actions?: unknown[]; + }; + if (payload?.contrat !== undefined && isValidContract(payload.contrat)) { + const contratsFils: Contrat[] = []; + if (Array.isArray(payload.contrats_fils)) { + for (const cf of payload.contrats_fils) { + if (isValidContract(cf)) { + contratsFils.push(cf); + } + } + } + const actions: Action[] = []; + if (Array.isArray(payload.actions)) { + for (const a of payload.actions) { + if (isValidAction(a)) { + actions.push(a); + } + } + } + updateValidatorsFromContract(payload.contrat, contratsFils, actions); + } else { + setStatus('Message contrat reçu mais structure invalide.', 'rejected'); + } + return; + } + if (d.type === 'login-proof') { const proof = d.payload as LoginProof; const result = verifyLoginProof(proof, { @@ -72,7 +128,10 @@ function init(): void { iframe.src = USERWALLET_ORIGIN; btnAuth?.addEventListener('click', sendAuthRequest); window.addEventListener('message', handleMessage); - setStatus('En attente du login depuis l’iframe.', 'pending'); + setStatus( + 'En attente du login depuis l’iframe. Utilisation des validateurs par défaut jusqu’à réception d’un contrat.', + 'pending', + ); } init();