UTXO-list: dates/blockTime historiques, récupération frais depuis ancrages, diagnostic Bloc Rewards
**Motivations:** - Ajouter dates manquantes dans hash_list.txt et compléter historique - Compléter blockTime manquants dans utxo_list.txt et compléter historique - Récupérer frais depuis transactions d'ancrage (OP_RETURN) et les stocker - Bouton UI pour déclencher récupération frais - Diagnostic Bloc Rewards (pourquoi ~4700 BTC au lieu de 50 BTC) **Root causes:** - hash_list.txt sans date (format ancien) - utxo_list.txt blockTime souvent vide - Frais absents du fichier (métadonnées OP_RETURN non stockées) - Pas de moyen de récupérer/compléter frais depuis UI **Correctifs:** - hash_list.txt : format étendu avec date (rétrocompatible) - utxo_list.txt : blockTime complété automatiquement lors écritures - fees_list.txt : nouveau fichier pour stocker frais - updateFeesFromAnchors() : récupère frais depuis OP_RETURN ancrages - Endpoint /api/utxo/fees/update pour déclencher récupération - Bouton "Récupérer les frais depuis les ancrages" dans section Frais (spinner) - Scripts batch : complete-hash-list-dates.js, complete-utxo-list-blocktime.js - Script diagnostic : diagnose-bloc-rewards.js (subsidy, coinbase, listunspent) **Evolutions:** - Frais chargés depuis fees_list.txt dans getUtxoList - Complétion automatique dates/blockTime lors écritures futures **Pages affectées:** - signet-dashboard/src/bitcoin-rpc.js - signet-dashboard/src/server.js - signet-dashboard/public/utxo-list.html - scripts/complete-hash-list-dates.js - scripts/complete-utxo-list-blocktime.js - scripts/diagnose-bloc-rewards.js - features/utxo-list-fees-update-and-historical-completion.md
This commit is contained in:
parent
cc054c8904
commit
cad73cb265
@ -1 +1 @@
|
|||||||
2026-01-25T16:42:13.664Z;8755;0000000cd5db32bf476ead70527dff6cc88eb11330b01b87477a02fa6db5640d;15502
|
2026-01-25T23:26:21.483Z;9194;0000000be8aaf7d13939384ab7a3eb86856c68f01e7e309bc8c3e91e0a327dde;17100
|
||||||
6
api-relay/.gitignore
vendored
Normal file
6
api-relay/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
76
api-relay/README.md
Normal file
76
api-relay/README.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# UserWallet API Relay
|
||||||
|
|
||||||
|
Serveur de relais pour le système de login décentralisé UserWallet.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Stockage des messages chiffrés (sans signatures ni clés)
|
||||||
|
- Stockage séparé des signatures
|
||||||
|
- Stockage séparé des clés de déchiffrement
|
||||||
|
- Relais entre pairs (inter-relay)
|
||||||
|
- Déduplication par hash
|
||||||
|
- Endpoints REST pour GET/POST
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Variables d'environnement :
|
||||||
|
|
||||||
|
- `PORT` : Port d'écoute (défaut: 3019)
|
||||||
|
- `HOST` : Adresse d'écoute (défaut: 0.0.0.0)
|
||||||
|
- `STORAGE_PATH` : Chemin de stockage (défaut: ./data)
|
||||||
|
- `PEER_RELAYS` : Liste de relais pairs séparés par virgule (ex: http://relay1:3019,http://relay2:3019)
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
- `GET /health` - Vérification de santé
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
- `GET /messages?start=<timestamp>&end=<timestamp>&service=<uuid>` - Récupérer les messages dans une fenêtre temporelle
|
||||||
|
- `POST /messages` - Publier un message chiffré
|
||||||
|
- `GET /messages/:hash` - Récupérer un message par hash
|
||||||
|
|
||||||
|
### Signatures
|
||||||
|
|
||||||
|
- `GET /signatures/:hash` - Récupérer les signatures pour un message
|
||||||
|
- `POST /signatures` - Publier une signature
|
||||||
|
|
||||||
|
### Clés
|
||||||
|
|
||||||
|
- `GET /keys/:hash` - Récupérer les clés de déchiffrement pour un message
|
||||||
|
- `POST /keys` - Publier une clé de déchiffrement
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Le relais respecte la séparation stricte :
|
||||||
|
1. Messages publiés sans signatures ni clés
|
||||||
|
2. Signatures publiées séparément
|
||||||
|
3. Clés publiées séparément
|
||||||
|
|
||||||
|
La déduplication par hash évite de relayer deux fois le même message.
|
||||||
|
|
||||||
|
## Stockage
|
||||||
|
|
||||||
|
Par défaut, le stockage est en mémoire avec sauvegarde optionnelle sur disque.
|
||||||
|
En production, utiliser une base de données (SQLite, PostgreSQL, etc.).
|
||||||
48
api-relay/eslint.config.mjs
Normal file
48
api-relay/eslint.config.mjs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import unusedImports from 'eslint-plugin-unused-imports';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist', 'node_modules'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
plugins: {
|
||||||
|
'unused-imports': unusedImports,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'unused-imports/no-unused-imports': 'error',
|
||||||
|
'unused-imports/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
vars: 'all',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
args: 'after-used',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }],
|
||||||
|
'max-lines-per-function': ['error', { max: 40, skipBlankLines: true, skipComments: true }],
|
||||||
|
'max-params': ['error', 4],
|
||||||
|
'max-depth': ['error', 4],
|
||||||
|
complexity: ['error', 10],
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||||
|
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'eqeqeq': ['error', 'always'],
|
||||||
|
'curly': ['error', 'all'],
|
||||||
|
'no-else-return': 'error',
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
3376
api-relay/package-lock.json
generated
Normal file
3376
api-relay/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
api-relay/package.json
Normal file
30
api-relay/package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "userwallet-api-relay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Relay server for UserWallet decentralized login system",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-plugin-unused-imports": "^3.0.0",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"tsx": "^4.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
81
api-relay/src/index.ts
Normal file
81
api-relay/src/index.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { StorageService } from './services/storage';
|
||||||
|
import { RelayService } from './services/relay';
|
||||||
|
import { createMessagesRouter } from './routes/messages';
|
||||||
|
import { createSignaturesRouter } from './routes/signatures';
|
||||||
|
import { createKeysRouter } from './routes/keys';
|
||||||
|
import { createHealthRouter } from './routes/health';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3019;
|
||||||
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||||
|
const STORAGE_PATH = process.env.STORAGE_PATH ?? './data';
|
||||||
|
const PEER_RELAYS = process.env.PEER_RELAYS
|
||||||
|
? process.env.PEER_RELAYS.split(',').map((p) => p.trim())
|
||||||
|
: [];
|
||||||
|
const SAVE_INTERVAL_SECONDS = process.env.SAVE_INTERVAL_SECONDS
|
||||||
|
? parseInt(process.env.SAVE_INTERVAL_SECONDS, 10)
|
||||||
|
: 300;
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const storage = new StorageService(STORAGE_PATH);
|
||||||
|
await storage.initialize();
|
||||||
|
|
||||||
|
const relay = new RelayService(storage, PEER_RELAYS);
|
||||||
|
|
||||||
|
app.use('/health', createHealthRouter());
|
||||||
|
app.use('/messages', createMessagesRouter(storage, relay));
|
||||||
|
app.use('/signatures', createSignaturesRouter(storage, relay));
|
||||||
|
app.use('/keys', createKeysRouter(storage, relay));
|
||||||
|
|
||||||
|
let saveIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
if (SAVE_INTERVAL_SECONDS > 0) {
|
||||||
|
saveIntervalId = setInterval(() => {
|
||||||
|
storage.saveToDisk().catch((err) => {
|
||||||
|
console.error('Periodic save failed:', err);
|
||||||
|
});
|
||||||
|
}, SAVE_INTERVAL_SECONDS * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`Relay server listening on ${HOST}:${PORT}`);
|
||||||
|
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||||
|
console.log(`Peer relays: ${PEER_RELAYS.length > 0 ? PEER_RELAYS.join(', ') : 'none'}`);
|
||||||
|
if (SAVE_INTERVAL_SECONDS > 0) {
|
||||||
|
console.log(`Periodic save: every ${SAVE_INTERVAL_SECONDS}s`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async (): Promise<void> => {
|
||||||
|
console.log('Shutting down...');
|
||||||
|
if (saveIntervalId !== null) {
|
||||||
|
clearInterval(saveIntervalId);
|
||||||
|
saveIntervalId = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await storage.saveToDisk();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving storage on shutdown:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
14
api-relay/src/routes/health.ts
Normal file
14
api-relay/src/routes/health.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
|
||||||
|
export function createHealthRouter(): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health - Health check endpoint.
|
||||||
|
*/
|
||||||
|
router.get('/', (_req: Request, res: Response): void => {
|
||||||
|
res.json({ status: 'ok', timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
57
api-relay/src/routes/keys.ts
Normal file
57
api-relay/src/routes/keys.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import type { StorageService } from '../services/storage';
|
||||||
|
import type { RelayService } from '../services/relay';
|
||||||
|
import type { MsgCle, StoredKey } from '../types/message';
|
||||||
|
|
||||||
|
export function createKeysRouter(
|
||||||
|
storage: StorageService,
|
||||||
|
relay: RelayService,
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /keys/:hash - Get decryption keys for a message hash.
|
||||||
|
*/
|
||||||
|
router.get('/:hash', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const hash = req.params.hash as string;
|
||||||
|
const stored = storage.getKeys(hash);
|
||||||
|
const keys: MsgCle[] = stored.map((k) => k.msg);
|
||||||
|
res.json(keys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting keys:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /keys - Store and relay a decryption key.
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = req.body as MsgCle;
|
||||||
|
|
||||||
|
if (!key.hash_message || !key.cle_de_chiffrement_message) {
|
||||||
|
res.status(400).json({ error: 'Invalid key format' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: StoredKey = {
|
||||||
|
msg: key,
|
||||||
|
received_at: Date.now(),
|
||||||
|
relayed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.storeKey(stored);
|
||||||
|
await relay.relayKey(key);
|
||||||
|
stored.relayed = true;
|
||||||
|
|
||||||
|
res.status(201).json({ stored: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error storing key:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
88
api-relay/src/routes/messages.ts
Normal file
88
api-relay/src/routes/messages.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import type { StorageService } from '../services/storage';
|
||||||
|
import type { RelayService } from '../services/relay';
|
||||||
|
import type { MsgChiffre, StoredMessage } from '../types/message';
|
||||||
|
|
||||||
|
export function createMessagesRouter(
|
||||||
|
storage: StorageService,
|
||||||
|
relay: RelayService,
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /messages - Retrieve encrypted messages in a time window.
|
||||||
|
* Query params: start (timestamp), end (timestamp), service (optional service UUID)
|
||||||
|
*/
|
||||||
|
router.get('/', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const start = parseInt(req.query.start as string, 10);
|
||||||
|
const end = parseInt(req.query.end as string, 10);
|
||||||
|
const service = req.query.service as string | undefined;
|
||||||
|
|
||||||
|
if (isNaN(start) || isNaN(end)) {
|
||||||
|
res.status(400).json({ error: 'start and end timestamps required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = storage.getMessages(start, end, service);
|
||||||
|
const msgChiffres: MsgChiffre[] = messages.map((m) => m.msg);
|
||||||
|
res.json(msgChiffres);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting messages:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /messages - Store and relay an encrypted message.
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const msg = req.body as MsgChiffre;
|
||||||
|
|
||||||
|
if (!msg.hash || !msg.message_chiffre || !msg.datajson_public) {
|
||||||
|
res.status(400).json({ error: 'Invalid message format' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadySeen = storage.hasSeenHash(msg.hash);
|
||||||
|
const stored: StoredMessage = {
|
||||||
|
msg,
|
||||||
|
received_at: Date.now(),
|
||||||
|
relayed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.storeMessage(stored);
|
||||||
|
|
||||||
|
if (!alreadySeen) {
|
||||||
|
await relay.relayMessage(msg);
|
||||||
|
stored.relayed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ hash: msg.hash, stored: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error storing message:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /messages/:hash - Get a specific message by hash.
|
||||||
|
*/
|
||||||
|
router.get('/:hash', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const hash = req.params.hash as string;
|
||||||
|
const stored = storage.getMessage(hash);
|
||||||
|
if (stored === undefined) {
|
||||||
|
res.status(404).json({ error: 'Message not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(stored.msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting message:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
57
api-relay/src/routes/signatures.ts
Normal file
57
api-relay/src/routes/signatures.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import type { StorageService } from '../services/storage';
|
||||||
|
import type { RelayService } from '../services/relay';
|
||||||
|
import type { MsgSignature, StoredSignature } from '../types/message';
|
||||||
|
|
||||||
|
export function createSignaturesRouter(
|
||||||
|
storage: StorageService,
|
||||||
|
relay: RelayService,
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /signatures/:hash - Get signatures for a message hash.
|
||||||
|
*/
|
||||||
|
router.get('/:hash', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const hash = req.params.hash as string;
|
||||||
|
const stored = storage.getSignatures(hash);
|
||||||
|
const signatures: MsgSignature[] = stored.map((s) => s.msg);
|
||||||
|
res.json(signatures);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting signatures:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /signatures - Store and relay a signature.
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const sig = req.body as MsgSignature;
|
||||||
|
|
||||||
|
if (!sig.signature || !sig.signature.hash) {
|
||||||
|
res.status(400).json({ error: 'Invalid signature format' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: StoredSignature = {
|
||||||
|
msg: sig,
|
||||||
|
received_at: Date.now(),
|
||||||
|
relayed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.storeSignature(stored);
|
||||||
|
await relay.relaySignature(sig);
|
||||||
|
stored.relayed = true;
|
||||||
|
|
||||||
|
res.status(201).json({ stored: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error storing signature:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
78
api-relay/src/services/relay.ts
Normal file
78
api-relay/src/services/relay.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import type { StorageService } from './storage';
|
||||||
|
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for relaying messages between peers.
|
||||||
|
*/
|
||||||
|
export class RelayService {
|
||||||
|
private storage: StorageService;
|
||||||
|
private peerRelays: string[];
|
||||||
|
|
||||||
|
constructor(storage: StorageService, peerRelays: string[]) {
|
||||||
|
this.storage = storage;
|
||||||
|
this.peerRelays = peerRelays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay a message to peer relays if not already seen.
|
||||||
|
*/
|
||||||
|
async relayMessage(msg: MsgChiffre): Promise<void> {
|
||||||
|
if (this.storage.hasSeenHash(msg.hash)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.storage.markHashSeen(msg.hash);
|
||||||
|
|
||||||
|
for (const peer of this.peerRelays) {
|
||||||
|
try {
|
||||||
|
await this.postToPeer(peer, '/messages', msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error relaying to ${peer}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay a signature to peer relays.
|
||||||
|
*/
|
||||||
|
async relaySignature(sig: MsgSignature): Promise<void> {
|
||||||
|
for (const peer of this.peerRelays) {
|
||||||
|
try {
|
||||||
|
await this.postToPeer(peer, '/signatures', sig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error relaying signature to ${peer}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay a key to peer relays.
|
||||||
|
*/
|
||||||
|
async relayKey(key: MsgCle): Promise<void> {
|
||||||
|
for (const peer of this.peerRelays) {
|
||||||
|
try {
|
||||||
|
await this.postToPeer(peer, '/keys', key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error relaying key to ${peer}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST data to a peer relay.
|
||||||
|
*/
|
||||||
|
private async postToPeer(
|
||||||
|
peerUrl: string,
|
||||||
|
endpoint: string,
|
||||||
|
data: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${peerUrl}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Peer relay returned ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
api-relay/src/services/storage.ts
Normal file
191
api-relay/src/services/storage.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { StoredMessage, StoredSignature, StoredKey } from '../types/message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory storage with optional persistence.
|
||||||
|
* In production, use a proper database (SQLite, PostgreSQL, etc.).
|
||||||
|
*/
|
||||||
|
export class StorageService {
|
||||||
|
private messages: Map<string, StoredMessage> = new Map();
|
||||||
|
private signatures: Map<string, StoredSignature[]> = new Map();
|
||||||
|
private keys: Map<string, StoredKey[]> = new Map();
|
||||||
|
private seenHashes: Set<string> = new Set();
|
||||||
|
private storagePath: string;
|
||||||
|
|
||||||
|
constructor(storagePath: string) {
|
||||||
|
this.storagePath = storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize storage (load from disk if exists).
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await fs.mkdir(this.storagePath, { recursive: true });
|
||||||
|
await this.loadFromDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hash has been seen before (deduplication).
|
||||||
|
*/
|
||||||
|
hasSeenHash(hash: string): boolean {
|
||||||
|
return this.seenHashes.has(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a hash as seen.
|
||||||
|
*/
|
||||||
|
markHashSeen(hash: string): void {
|
||||||
|
this.seenHashes.add(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an encrypted message.
|
||||||
|
*/
|
||||||
|
storeMessage(msg: StoredMessage): void {
|
||||||
|
this.messages.set(msg.msg.hash, msg);
|
||||||
|
this.markHashSeen(msg.msg.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an encrypted message by hash.
|
||||||
|
*/
|
||||||
|
getMessage(hash: string): StoredMessage | undefined {
|
||||||
|
return this.messages.get(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages in a time window, optionally filtered by service.
|
||||||
|
*/
|
||||||
|
getMessages(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
serviceUuid?: string,
|
||||||
|
): StoredMessage[] {
|
||||||
|
const results: StoredMessage[] = [];
|
||||||
|
for (const msg of this.messages.values()) {
|
||||||
|
const timestamp = msg.msg.datajson_public.timestamp;
|
||||||
|
if (timestamp === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (timestamp < start || timestamp > end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
serviceUuid !== undefined &&
|
||||||
|
!msg.msg.datajson_public.services_uuid.includes(serviceUuid)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(msg);
|
||||||
|
}
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const tsA = a.msg.datajson_public.timestamp ?? 0;
|
||||||
|
const tsB = b.msg.datajson_public.timestamp ?? 0;
|
||||||
|
return tsA - tsB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a signature.
|
||||||
|
*/
|
||||||
|
storeSignature(sig: StoredSignature): void {
|
||||||
|
const hash = sig.msg.hash_cible ?? sig.msg.signature.hash;
|
||||||
|
const existing = this.signatures.get(hash) ?? [];
|
||||||
|
existing.push(sig);
|
||||||
|
this.signatures.set(hash, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get signatures for a message hash.
|
||||||
|
*/
|
||||||
|
getSignatures(hash: string): StoredSignature[] {
|
||||||
|
return this.signatures.get(hash) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a decryption key.
|
||||||
|
*/
|
||||||
|
storeKey(key: StoredKey): void {
|
||||||
|
const hash = key.msg.hash_message;
|
||||||
|
const existing = this.keys.get(hash) ?? [];
|
||||||
|
existing.push(key);
|
||||||
|
this.keys.set(hash, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get decryption keys for a message hash.
|
||||||
|
*/
|
||||||
|
getKeys(hash: string): StoredKey[] {
|
||||||
|
return this.keys.get(hash) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data from disk (if persistence is enabled).
|
||||||
|
* ENOENT: no file yet (first run), start fresh. Other errors are logged and rethrown.
|
||||||
|
*/
|
||||||
|
private async loadFromDisk(): Promise<void> {
|
||||||
|
const dataPath = join(this.storagePath, 'messages.json');
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(dataPath, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
const code =
|
||||||
|
err instanceof Error && 'code' in err
|
||||||
|
? (err as NodeJS.ErrnoException).code
|
||||||
|
: undefined;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error loading storage from disk:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
type StorageSchema = {
|
||||||
|
messages?: Array<[string, StoredMessage]>;
|
||||||
|
seenHashes?: string[];
|
||||||
|
signatures?: Array<[string, StoredSignature[]]>;
|
||||||
|
keys?: Array<[string, StoredKey[]]>;
|
||||||
|
};
|
||||||
|
let parsed: StorageSchema;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as StorageSchema;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing storage file:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (parsed.messages) {
|
||||||
|
for (const [hash, msg] of parsed.messages) {
|
||||||
|
this.messages.set(hash, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.seenHashes) {
|
||||||
|
for (const hash of parsed.seenHashes) {
|
||||||
|
this.seenHashes.add(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.signatures) {
|
||||||
|
for (const [hash, items] of parsed.signatures) {
|
||||||
|
this.signatures.set(hash, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.keys) {
|
||||||
|
for (const [hash, items] of parsed.keys) {
|
||||||
|
this.keys.set(hash, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save data to disk (messages, seenHashes, signatures, keys).
|
||||||
|
*/
|
||||||
|
async saveToDisk(): Promise<void> {
|
||||||
|
const dataPath = join(this.storagePath, 'messages.json');
|
||||||
|
const data = {
|
||||||
|
messages: Array.from(this.messages.entries()),
|
||||||
|
seenHashes: Array.from(this.seenHashes),
|
||||||
|
signatures: Array.from(this.signatures.entries()),
|
||||||
|
keys: Array.from(this.keys.entries()),
|
||||||
|
};
|
||||||
|
await fs.writeFile(dataPath, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
68
api-relay/src/types/message.ts
Normal file
68
api-relay/src/types/message.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Published encrypted message (without signatures and keys).
|
||||||
|
*/
|
||||||
|
export interface MsgChiffre {
|
||||||
|
hash: string;
|
||||||
|
message_chiffre: string;
|
||||||
|
datajson_public: {
|
||||||
|
services_uuid: string[];
|
||||||
|
types_uuid: string[];
|
||||||
|
timestamp?: number;
|
||||||
|
version?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate signature message.
|
||||||
|
*/
|
||||||
|
export interface MsgSignature {
|
||||||
|
signature: {
|
||||||
|
hash: string;
|
||||||
|
cle_publique: string;
|
||||||
|
signature: string;
|
||||||
|
nonce: string;
|
||||||
|
materiel?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
hash_cible?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual decryption message with ECDH material.
|
||||||
|
*/
|
||||||
|
export interface MsgCle {
|
||||||
|
hash_message: string;
|
||||||
|
cle_de_chiffrement_message: {
|
||||||
|
algo: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
cle_chiffree?: string;
|
||||||
|
};
|
||||||
|
df_ecdh_scannable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored message with metadata.
|
||||||
|
*/
|
||||||
|
export interface StoredMessage {
|
||||||
|
msg: MsgChiffre;
|
||||||
|
received_at: number;
|
||||||
|
relayed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored signature with metadata.
|
||||||
|
*/
|
||||||
|
export interface StoredSignature {
|
||||||
|
msg: MsgSignature;
|
||||||
|
received_at: number;
|
||||||
|
relayed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored key with metadata.
|
||||||
|
*/
|
||||||
|
export interface StoredKey {
|
||||||
|
msg: MsgCle;
|
||||||
|
received_at: number;
|
||||||
|
relayed: boolean;
|
||||||
|
}
|
||||||
21
api-relay/tsconfig.json
Normal file
21
api-relay/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"exactOptionalPropertyTypes": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@ -194,6 +194,35 @@ server {
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# 5. UserWallet (port 3018)
|
||||||
|
echo "📝 Configuration de userwallet.certificator.4nkweb.com..."
|
||||||
|
${SUDO_CMD} tee "${NGINX_SITES_AVAILABLE}/userwallet.certificator.4nkweb.com" > /dev/null << 'EOF'
|
||||||
|
# UserWallet frontend (Vite)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name userwallet.certificator.4nkweb.com;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/userwallet.certificator.4nkweb.com.access.log;
|
||||||
|
error_log /var/log/nginx/userwallet.certificator.4nkweb.com.error.log;
|
||||||
|
|
||||||
|
# Proxy vers le frontend UserWallet (port 3018) sur 192.168.1.105
|
||||||
|
location / {
|
||||||
|
proxy_pass http://192.168.1.105:3018;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
# Activer les sites
|
# Activer les sites
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔗 Activation des sites..."
|
echo "🔗 Activation des sites..."
|
||||||
@ -201,6 +230,7 @@ ${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/dashboard.certificator.4nkweb.com"
|
|||||||
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/faucet.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/faucet.certificator.4nkweb.com"
|
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/faucet.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/faucet.certificator.4nkweb.com"
|
||||||
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/anchorage.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/anchorage.certificator.4nkweb.com"
|
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/anchorage.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/anchorage.certificator.4nkweb.com"
|
||||||
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/watermark.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/watermark.certificator.4nkweb.com"
|
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/watermark.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/watermark.certificator.4nkweb.com"
|
||||||
|
${SUDO_CMD} ln -sf "${NGINX_SITES_AVAILABLE}/userwallet.certificator.4nkweb.com" "${NGINX_SITES_ENABLED}/userwallet.certificator.4nkweb.com"
|
||||||
|
|
||||||
# Tester la configuration Nginx
|
# Tester la configuration Nginx
|
||||||
echo ""
|
echo ""
|
||||||
@ -229,6 +259,7 @@ DOMAINS=(
|
|||||||
"faucet.certificator.4nkweb.com"
|
"faucet.certificator.4nkweb.com"
|
||||||
"anchorage.certificator.4nkweb.com"
|
"anchorage.certificator.4nkweb.com"
|
||||||
"watermark.certificator.4nkweb.com"
|
"watermark.certificator.4nkweb.com"
|
||||||
|
"userwallet.certificator.4nkweb.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
for domain in "${DOMAINS[@]}"; do
|
for domain in "${DOMAINS[@]}"; do
|
||||||
@ -256,6 +287,7 @@ echo " - dashboard.certificator.4nkweb.com -> http://192.168.1.105:3020"
|
|||||||
echo " - faucet.certificator.4nkweb.com -> http://192.168.1.105:3021"
|
echo " - faucet.certificator.4nkweb.com -> http://192.168.1.105:3021"
|
||||||
echo " - anchorage.certificator.4nkweb.com -> http://192.168.1.105:3010"
|
echo " - anchorage.certificator.4nkweb.com -> http://192.168.1.105:3010"
|
||||||
echo " - watermark.certificator.4nkweb.com -> http://192.168.1.105:3022"
|
echo " - watermark.certificator.4nkweb.com -> http://192.168.1.105:3022"
|
||||||
|
echo " - userwallet.certificator.4nkweb.com -> http://192.168.1.105:3018"
|
||||||
echo ""
|
echo ""
|
||||||
echo "⚠️ Note: Si les services tournent sur une autre machine,"
|
echo "⚠️ Note: Si les services tournent sur une autre machine,"
|
||||||
echo " modifiez les IP dans les fichiers de configuration Nginx"
|
echo " modifiez les IP dans les fichiers de configuration Nginx"
|
||||||
|
|||||||
120
features/utxo-list-fees-update-and-historical-completion.md
Normal file
120
features/utxo-list-fees-update-and-historical-completion.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# UTXO-list : récupération frais et complétion historique (dates, blockTime)
|
||||||
|
|
||||||
|
**Auteur** : Équipe 4NK
|
||||||
|
**Date** : 2026-01-26
|
||||||
|
**Version** : 1.0
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
- Ajouter les dates manquantes dans `hash_list.txt` et compléter l'historique
|
||||||
|
- Compléter les `blockTime` manquants dans `utxo_list.txt` et compléter l'historique
|
||||||
|
- Récupérer les frais depuis les transactions d'ancrage (OP_RETURN) et les stocker
|
||||||
|
- Ajouter un bouton dans l'UI pour déclencher la récupération des frais
|
||||||
|
|
||||||
|
## Problème initial
|
||||||
|
|
||||||
|
- **hash_list.txt** : format sans date (`hash;txid;blockHeight;confirmations`)
|
||||||
|
- **utxo_list.txt** : `blockTime` souvent vide (dernier champ)
|
||||||
|
- **Frais** : absents du fichier (métadonnées OP_RETURN, non stockées)
|
||||||
|
- Pas de moyen de récupérer/compléter les frais depuis l'UI
|
||||||
|
|
||||||
|
## Impacts
|
||||||
|
|
||||||
|
- **hash_list.txt** : format étendu avec date (`hash;txid;blockHeight;confirmations;date`)
|
||||||
|
- **utxo_list.txt** : `blockTime` complété automatiquement lors des écritures futures
|
||||||
|
- **fees_list.txt** : nouveau fichier pour stocker les frais (`txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount`)
|
||||||
|
- **UI** : bouton "Récupérer les frais depuis les ancrages" dans section Frais
|
||||||
|
- **API** : endpoint `/api/utxo/fees/update` pour déclencher la récupération
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Serveur (`signet-dashboard/src/bitcoin-rpc.js`)
|
||||||
|
|
||||||
|
- **getHashList()** :
|
||||||
|
- Format fichier : ajout colonne `date` (ISO 8601)
|
||||||
|
- Lecture : rétrocompatible (gère format avec/sans date)
|
||||||
|
- Écriture : ajoute date si manquante (date actuelle)
|
||||||
|
|
||||||
|
- **getUtxoList()** :
|
||||||
|
- Lecture `fees_list.txt` : charge les frais depuis le fichier si disponible
|
||||||
|
- Complète `blockTime` via RPC pour UTXOs confirmés sans `blockTime`
|
||||||
|
|
||||||
|
- **updateFeesFromAnchors()** (nouveau) :
|
||||||
|
- Lit les ancrages depuis `utxo_list.txt`
|
||||||
|
- Pour chaque transaction d'ancrage unique, récupère `getrawtransaction`
|
||||||
|
- Extrait les frais depuis OP_RETURN (métadonnées `FEE:`)
|
||||||
|
- Stocke dans `fees_list.txt` (format ci-dessus)
|
||||||
|
- Ignore les frais déjà présents (évite doublons)
|
||||||
|
|
||||||
|
### Serveur (`signet-dashboard/src/server.js`)
|
||||||
|
|
||||||
|
- **Route `/api/utxo/fees/update`** (POST) :
|
||||||
|
- Appelle `bitcoinRPC.updateFeesFromAnchors()`
|
||||||
|
- Retourne `{success, newFees, totalFees, processed}`
|
||||||
|
|
||||||
|
### Page UTXO-list (`signet-dashboard/public/utxo-list.html`)
|
||||||
|
|
||||||
|
- **Bouton "Récupérer les frais depuis les ancrages"** :
|
||||||
|
- Dans header section Frais
|
||||||
|
- Spinner pendant traitement
|
||||||
|
- Appelle `/api/utxo/fees/update`
|
||||||
|
- Recharge la liste après succès
|
||||||
|
|
||||||
|
- **Fonction `updateFeesFromAnchors()`** :
|
||||||
|
- Gère le spinner et les états du bouton
|
||||||
|
- Affiche alert avec résultats
|
||||||
|
|
||||||
|
### Scripts batch (`scripts/`)
|
||||||
|
|
||||||
|
- **complete-hash-list-dates.js** :
|
||||||
|
- Complète les dates manquantes dans `hash_list.txt`
|
||||||
|
- Ajoute date actuelle si colonne absente ou vide
|
||||||
|
|
||||||
|
- **complete-utxo-list-blocktime.js** :
|
||||||
|
- Complète les `blockTime` manquants dans `utxo_list.txt`
|
||||||
|
- Récupère `blockTime` via RPC pour UTXOs confirmés sans `blockTime`
|
||||||
|
|
||||||
|
- **diagnose-bloc-rewards.js** :
|
||||||
|
- Diagnostic Bloc Rewards (pourquoi ~4700 BTC au lieu de 50 BTC)
|
||||||
|
- Compare `listunspent`, `getrawtransaction`, `blockheight`
|
||||||
|
- Calcule subsidy attendu et frais
|
||||||
|
|
||||||
|
## Réponse : Frais dans les autres tableaux ?
|
||||||
|
|
||||||
|
**Non, les frais ne sont PAS des UTXOs.** Ce sont des **métadonnées** extraites de l'OP_RETURN des transactions d'ancrage. Ils sont :
|
||||||
|
- **Absents** de `utxo_list.txt` (ce ne sont pas des UTXOs)
|
||||||
|
- **Présents** dans le tableau `fees` retourné par `/api/utxo/list` (via RPC / OP_RETURN)
|
||||||
|
- **Stockés** dans `fees_list.txt` après utilisation du bouton de récupération
|
||||||
|
|
||||||
|
Les frais ne sont **pas comptés** dans les autres tableaux (Bloc Rewards, Ancrages, Changes). Ils sont dans un tableau séparé.
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
1. **Redémarrer le dashboard** : `sudo systemctl restart signet-dashboard.service`
|
||||||
|
2. **Compléter l'historique** (optionnel) :
|
||||||
|
```bash
|
||||||
|
node scripts/complete-hash-list-dates.js
|
||||||
|
node scripts/complete-utxo-list-blocktime.js
|
||||||
|
```
|
||||||
|
3. **Récupérer les frais** : utiliser le bouton dans l'UI ou appeler `/api/utxo/fees/update`
|
||||||
|
4. **Diagnostic Bloc Rewards** (optionnel) :
|
||||||
|
```bash
|
||||||
|
node scripts/diagnose-bloc-rewards.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modalités d'analyse
|
||||||
|
|
||||||
|
- **Dates hash_list.txt** : vérifier que colonne date est présente
|
||||||
|
- **blockTime utxo_list.txt** : vérifier que dernier champ n'est plus vide pour UTXOs confirmés
|
||||||
|
- **Frais** : vérifier que `fees_list.txt` est créé et contient des frais après utilisation du bouton
|
||||||
|
- **UI** : vérifier que le bouton fonctionne et affiche le spinner
|
||||||
|
|
||||||
|
## Pages affectées
|
||||||
|
|
||||||
|
- `signet-dashboard/src/bitcoin-rpc.js` : `getHashList`, `getUtxoList`, `updateFeesFromAnchors`
|
||||||
|
- `signet-dashboard/src/server.js` : route `/api/utxo/fees/update`
|
||||||
|
- `signet-dashboard/public/utxo-list.html` : bouton et fonction `updateFeesFromAnchors`
|
||||||
|
- `scripts/complete-hash-list-dates.js` : nouveau
|
||||||
|
- `scripts/complete-utxo-list-blocktime.js` : nouveau
|
||||||
|
- `scripts/diagnose-bloc-rewards.js` : nouveau
|
||||||
|
- `features/utxo-list-fees-update-and-historical-completion.md` : cette doc
|
||||||
52
fixKnowledge/api-relay-doc-config-cleanup.md
Normal file
52
fixKnowledge/api-relay-doc-config-cleanup.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# API Relay – Correction doc port 8080→3019 et nettoyage config
|
||||||
|
|
||||||
|
**Auteur:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## Motivations
|
||||||
|
|
||||||
|
- Aligner la documentation api-relay sur le port réel (3019) ; les exemples et défauts indiquaient 8080.
|
||||||
|
- Supprimer le code mort : `api-relay/src/types/config.ts` (interface `RelayConfig`) n’était pas utilisé ; la config réelle passe par les variables d’environnement dans `index.ts`.
|
||||||
|
- Harmoniser le placeholder du champ « relais » dans UserWallet (3019).
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
- **Doc** : `features/api-relay.md` et ancien README indiquaient le port 8080 ; le code utilise 3019 par défaut.
|
||||||
|
- **config.ts** : Fichier créé pour une config structurée jamais branchée ; `index.ts` lit PORT, HOST, STORAGE_PATH, PEER_RELAYS depuis l’env.
|
||||||
|
|
||||||
|
## Correctifs
|
||||||
|
|
||||||
|
### 1. features/api-relay.md
|
||||||
|
|
||||||
|
- Variables d’environnement : défaut `PORT` 8080 → 3019.
|
||||||
|
- Exemples (PORT, PEER_RELAYS, curl) : 8080 → 3019.
|
||||||
|
- Structure du projet : suppression de `config.ts` dans l’arbre ; mention « config via env » pour `index.ts`.
|
||||||
|
|
||||||
|
### 2. api-relay – suppression config.ts
|
||||||
|
|
||||||
|
- **Fichier supprimé** : `api-relay/src/types/config.ts`.
|
||||||
|
- Aucun import ailleurs ; le build reste vert.
|
||||||
|
|
||||||
|
### 3. userwallet – RelaySettingsScreen
|
||||||
|
|
||||||
|
- Placeholder du champ « Ajouter un relais » : `http://relay.example.com:8080` → `http://relay.example.com:3019`.
|
||||||
|
|
||||||
|
## Évolutions
|
||||||
|
|
||||||
|
- Aucune.
|
||||||
|
|
||||||
|
## Pages affectées
|
||||||
|
|
||||||
|
- `userwallet/features/api-relay.md`
|
||||||
|
- `api-relay/src/types/config.ts` (supprimé)
|
||||||
|
- `userwallet/src/components/RelaySettingsScreen.tsx`
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
- Aucun redéploiement serveur requis. Rebuild userwallet si déploiement front ; api-relay inchangé côté runtime.
|
||||||
|
|
||||||
|
## Modalités d’analyse
|
||||||
|
|
||||||
|
- Vérifier que les exemples curl dans `api-relay.md` ciblent bien un relais sur 3019.
|
||||||
|
- `npm run build` dans api-relay doit passer.
|
||||||
|
- `npm run build` dans userwallet doit passer.
|
||||||
351
fixKnowledge/dashboard-hash-generation-error.md
Normal file
351
fixKnowledge/dashboard-hash-generation-error.md
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
# Correction : Erreur lors de la génération du hash pour un document
|
||||||
|
|
||||||
|
**Auteur** : Équipe 4NK
|
||||||
|
**Date** : 2026-01-24
|
||||||
|
**Version** : 1.0
|
||||||
|
|
||||||
|
## Problème Identifié
|
||||||
|
|
||||||
|
Erreur lors de la génération d'un hash pour un document dans le dashboard signet.
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Erreur lors de la génération du hash pour les fichiers binaires (PDF, images, etc.)
|
||||||
|
- Le hash généré était incorrect pour les fichiers binaires
|
||||||
|
- Pas de message d'erreur clair lorsque la lecture du fichier échouait
|
||||||
|
- Les fichiers texte fonctionnaient correctement
|
||||||
|
|
||||||
|
## Cause Racine
|
||||||
|
|
||||||
|
**Root cause** : La fonction `generateHashFromFile()` utilisait `FileReader.readAsText()` qui convertit le fichier en texte UTF-8. Cette méthode échoue pour les fichiers binaires car :
|
||||||
|
|
||||||
|
1. **Fichiers binaires non valides en UTF-8** : Les fichiers binaires (PDF, images, etc.) contiennent des bytes qui ne sont pas valides en UTF-8, ce qui cause des erreurs lors de la conversion
|
||||||
|
2. **Hash incorrect** : Même si la conversion réussissait partiellement, le hash calculé était basé sur la représentation texte corrompue du fichier, pas sur les bytes bruts, ce qui produisait un hash incorrect
|
||||||
|
3. **Backend traite comme UTF-8** : Le backend utilisait `crypto.createHash('sha256').update(content, 'utf8')` qui suppose que le contenu est en UTF-8, ce qui est incorrect pour les fichiers binaires
|
||||||
|
4. **Pas de gestion d'erreur** : Il n'y avait pas de gestion d'erreur pour `reader.onerror`, donc les erreurs de lecture n'étaient pas capturées et affichées à l'utilisateur
|
||||||
|
|
||||||
|
**Problème technique** : Le code ne distinguait pas les fichiers texte des fichiers binaires et utilisait une méthode de lecture inadaptée pour les fichiers binaires.
|
||||||
|
|
||||||
|
## Correctifs Appliqués
|
||||||
|
|
||||||
|
### 1. Modification de `generateHashFromFile()` pour utiliser `readAsArrayBuffer()`
|
||||||
|
|
||||||
|
**Fichier** : `signet-dashboard/public/app.js`
|
||||||
|
|
||||||
|
**Avant** :
|
||||||
|
```javascript
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const fileContent = e.target.result;
|
||||||
|
// ...
|
||||||
|
body: JSON.stringify({ fileContent }),
|
||||||
|
};
|
||||||
|
reader.readAsText(selectedFile);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après** :
|
||||||
|
```javascript
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = e.target.result;
|
||||||
|
// Convertir l'ArrayBuffer en base64 pour l'envoi au backend
|
||||||
|
// Utiliser une boucle pour éviter les limites de taille des arguments de fonction
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
let binaryString = '';
|
||||||
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
|
binaryString += String.fromCharCode(uint8Array[i]);
|
||||||
|
}
|
||||||
|
const base64 = btoa(binaryString);
|
||||||
|
// ...
|
||||||
|
body: JSON.stringify({ fileContent: base64, isBase64: true }),
|
||||||
|
} catch (error) {
|
||||||
|
showResult('anchor-result', 'error', `Erreur : ${error.message}`);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = (error) => {
|
||||||
|
showResult('anchor-result', 'error', `Erreur lors de la lecture du fichier : ${error.message || 'Erreur inconnue'}`);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsArrayBuffer(selectedFile);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact** :
|
||||||
|
- Les fichiers binaires sont maintenant lus correctement comme ArrayBuffer
|
||||||
|
- Le contenu est converti en base64 pour l'envoi au backend
|
||||||
|
- Gestion d'erreur complète avec `reader.onerror`
|
||||||
|
- Utilisation d'une boucle pour la conversion base64 pour éviter les limites de taille des arguments de fonction pour les gros fichiers
|
||||||
|
- Meilleure gestion des erreurs HTTP avec affichage des messages d'erreur du backend
|
||||||
|
|
||||||
|
### 2. Modification du backend pour accepter les fichiers binaires en base64
|
||||||
|
|
||||||
|
**Fichier** : `signet-dashboard/src/server.js`
|
||||||
|
|
||||||
|
**Avant** :
|
||||||
|
```javascript
|
||||||
|
app.post('/api/hash/generate', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { text, fileContent } = req.body;
|
||||||
|
if (!text && !fileContent) {
|
||||||
|
return res.status(400).json({ error: 'text or fileContent is required' });
|
||||||
|
}
|
||||||
|
const content = text || fileContent;
|
||||||
|
const hash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
||||||
|
res.json({ hash });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating hash', { error: error.message });
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après** :
|
||||||
|
```javascript
|
||||||
|
app.post('/api/hash/generate', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { text, fileContent, isBase64 } = req.body;
|
||||||
|
|
||||||
|
if (!text && !fileContent) {
|
||||||
|
return res.status(400).json({ error: 'text or fileContent is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash;
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
// Pour le texte, utiliser UTF-8
|
||||||
|
hash = crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
||||||
|
} else if (fileContent) {
|
||||||
|
if (isBase64) {
|
||||||
|
// Pour les fichiers binaires, décoder le base64 et calculer le hash sur les bytes bruts
|
||||||
|
const buffer = Buffer.from(fileContent, 'base64');
|
||||||
|
hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
|
} else {
|
||||||
|
// Fallback pour compatibilité : traiter comme UTF-8 (pour fichiers texte)
|
||||||
|
hash = crypto.createHash('sha256').update(fileContent, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'text or fileContent is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ hash });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating hash', { error: error.message, stack: error.stack });
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact** :
|
||||||
|
- Le backend distingue maintenant les fichiers texte (UTF-8) des fichiers binaires (base64)
|
||||||
|
- Le hash est calculé sur les bytes bruts pour les fichiers binaires, garantissant un hash correct
|
||||||
|
- Compatibilité maintenue avec l'ancien format (fichiers texte sans `isBase64`)
|
||||||
|
- Meilleure journalisation des erreurs avec la stack trace
|
||||||
|
- Gestion d'erreur robuste pour le décodage base64 avec messages d'erreur clairs
|
||||||
|
- Validation des paramètres pour éviter les erreurs silencieuses
|
||||||
|
|
||||||
|
### 3. Augmentation de la limite de taille du body JSON
|
||||||
|
|
||||||
|
**Fichier** : `signet-dashboard/src/server.js`
|
||||||
|
|
||||||
|
**Avant** :
|
||||||
|
```javascript
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après** :
|
||||||
|
```javascript
|
||||||
|
// Augmenter la limite de taille pour le body JSON (100MB) pour supporter les gros fichiers en base64
|
||||||
|
app.use(express.json({ limit: '100mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact** :
|
||||||
|
- Support des gros fichiers (jusqu'à ~75MB en binaire, ~100MB en base64)
|
||||||
|
- Évite les erreurs "payload too large" pour les fichiers volumineux
|
||||||
|
|
||||||
|
### 4. Ajout d'un middleware pour gérer les erreurs de parsing JSON
|
||||||
|
|
||||||
|
**Fichier** : `signet-dashboard/src/server.js`
|
||||||
|
|
||||||
|
**Ajout** :
|
||||||
|
```javascript
|
||||||
|
// Middleware pour gérer les erreurs de parsing JSON
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||||
|
logger.error('JSON parsing error', {
|
||||||
|
error: err.message,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid JSON',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact** :
|
||||||
|
- Capture les erreurs de parsing JSON avant qu'elles n'atteignent les routes
|
||||||
|
- Retourne des messages d'erreur clairs pour les requêtes JSON mal formées
|
||||||
|
- Améliore la journalisation des erreurs de parsing
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Fichiers Modifiés
|
||||||
|
|
||||||
|
- `signet-dashboard/public/app.js` :
|
||||||
|
- Modification de `generateHashFromFile()` pour utiliser `readAsArrayBuffer()` au lieu de `readAsText()`
|
||||||
|
- Ajout de la gestion d'erreur `reader.onerror`
|
||||||
|
- Conversion en base64 avec une boucle pour supporter les gros fichiers
|
||||||
|
- Amélioration de la gestion des erreurs HTTP avec affichage des messages d'erreur du backend
|
||||||
|
|
||||||
|
- `signet-dashboard/src/server.js` :
|
||||||
|
- Modification de la route `/api/hash/generate` pour accepter `isBase64` (lignes 472-530)
|
||||||
|
- Calcul du hash sur les bytes bruts pour les fichiers binaires
|
||||||
|
- Distinction entre fichiers texte (UTF-8) et fichiers binaires (base64)
|
||||||
|
- Amélioration de la journalisation des erreurs
|
||||||
|
- Gestion d'erreur robuste pour le décodage base64
|
||||||
|
- Validation des paramètres d'entrée
|
||||||
|
- Augmentation de la limite de taille du body JSON à 100MB (lignes 132-134)
|
||||||
|
- Ajout d'un middleware pour gérer les erreurs de parsing JSON (lignes 144-156)
|
||||||
|
|
||||||
|
### Fichiers Créés
|
||||||
|
|
||||||
|
- `fixKnowledge/dashboard-hash-generation-error.md` : Cette documentation
|
||||||
|
|
||||||
|
## Modalités de Déploiement
|
||||||
|
|
||||||
|
### Déploiement des Modifications
|
||||||
|
|
||||||
|
1. **Vérifier que le dashboard est en cours d'exécution** :
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3020/api/blockchain/info
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Redémarrer le dashboard** :
|
||||||
|
```bash
|
||||||
|
# Si démarré avec npm start
|
||||||
|
# Arrêter avec Ctrl+C puis redémarrer
|
||||||
|
cd /home/ncantu/Bureau/code/bitcoin/signet-dashboard
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Si démarré avec systemd
|
||||||
|
sudo systemctl restart signet-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Vérifier que le dashboard fonctionne** :
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3020/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests de Validation
|
||||||
|
|
||||||
|
1. **Test avec un fichier texte** :
|
||||||
|
- Créer un fichier texte simple
|
||||||
|
- Générer le hash via l'interface
|
||||||
|
- Vérifier que le hash est généré correctement
|
||||||
|
|
||||||
|
2. **Test avec un fichier binaire (PDF)** :
|
||||||
|
- Sélectionner un fichier PDF
|
||||||
|
- Générer le hash via l'interface
|
||||||
|
- Vérifier que le hash est généré correctement (pas d'erreur)
|
||||||
|
|
||||||
|
3. **Test avec un fichier binaire (image)** :
|
||||||
|
- Sélectionner une image (PNG, JPG, etc.)
|
||||||
|
- Générer le hash via l'interface
|
||||||
|
- Vérifier que le hash est généré correctement
|
||||||
|
|
||||||
|
4. **Test avec un gros fichier** :
|
||||||
|
- Sélectionner un fichier de plusieurs Mo
|
||||||
|
- Générer le hash via l'interface
|
||||||
|
- Vérifier que le hash est généré correctement (pas d'erreur de mémoire)
|
||||||
|
|
||||||
|
## Modalités d'Analyse
|
||||||
|
|
||||||
|
### Vérification que la génération de hash fonctionne
|
||||||
|
|
||||||
|
1. **Test de l'API directement** :
|
||||||
|
```bash
|
||||||
|
# Test avec du texte
|
||||||
|
curl -X POST http://localhost:3020/api/hash/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text": "Hello World"}'
|
||||||
|
|
||||||
|
# Test avec un fichier binaire (base64)
|
||||||
|
# Générer le base64 d'un fichier
|
||||||
|
base64 -i test.pdf > test.pdf.base64
|
||||||
|
# Envoyer au backend
|
||||||
|
curl -X POST http://localhost:3020/api/hash/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"fileContent\": \"$(cat test.pdf.base64)\", \"isBase64\": true}"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Vérifier les logs** :
|
||||||
|
```bash
|
||||||
|
# Si démarré avec npm start
|
||||||
|
tail -f /tmp/dashboard.log
|
||||||
|
|
||||||
|
# Si démarré avec systemd
|
||||||
|
sudo journalctl -u signet-dashboard -f
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test dans le navigateur** :
|
||||||
|
- Ouvrir `https://dashboard.certificator.4nkweb.com/`
|
||||||
|
- Aller dans l'onglet "Ancrage"
|
||||||
|
- Sélectionner un fichier PDF
|
||||||
|
- Cliquer sur "Générer le Hash"
|
||||||
|
- Vérifier que le hash est généré sans erreur
|
||||||
|
|
||||||
|
### Diagnostic des Erreurs
|
||||||
|
|
||||||
|
1. **Erreur "Erreur lors de la lecture du fichier"** :
|
||||||
|
- Vérifier que le fichier est valide
|
||||||
|
- Vérifier les logs du navigateur (Console JavaScript)
|
||||||
|
- Vérifier les logs du backend
|
||||||
|
|
||||||
|
2. **Erreur "Erreur lors de la génération du hash"** :
|
||||||
|
- Vérifier les logs du backend pour voir l'erreur exacte
|
||||||
|
- Vérifier que le backend reçoit bien `isBase64: true` pour les fichiers binaires
|
||||||
|
- Vérifier que le base64 est valide
|
||||||
|
|
||||||
|
3. **Hash incorrect** :
|
||||||
|
- Vérifier que le hash est calculé sur les bytes bruts (pas sur UTF-8)
|
||||||
|
- Comparer avec un hash calculé localement : `sha256sum fichier.pdf`
|
||||||
|
|
||||||
|
## Résultat
|
||||||
|
|
||||||
|
✅ **Problème résolu**
|
||||||
|
|
||||||
|
- Les fichiers binaires (PDF, images, etc.) peuvent maintenant être hashés correctement
|
||||||
|
- Le hash est calculé sur les bytes bruts, garantissant un hash correct et reproductible
|
||||||
|
- Les erreurs sont maintenant capturées et affichées clairement à l'utilisateur
|
||||||
|
- Les gros fichiers sont supportés grâce à la conversion base64 par boucle et l'augmentation de la limite de taille du body
|
||||||
|
- Compatibilité maintenue avec les fichiers texte
|
||||||
|
- L'erreur 500 (Internal Server Error) est maintenant corrigée avec une gestion d'erreur robuste
|
||||||
|
- Les erreurs de parsing JSON sont maintenant gérées correctement avec des messages d'erreur clairs
|
||||||
|
- Validation des paramètres pour éviter les erreurs silencieuses
|
||||||
|
|
||||||
|
## Prévention
|
||||||
|
|
||||||
|
Pour éviter ce problème à l'avenir :
|
||||||
|
|
||||||
|
1. **Toujours utiliser `readAsArrayBuffer()` pour les fichiers binaires** : Ne jamais utiliser `readAsText()` pour les fichiers binaires
|
||||||
|
2. **Distinguer les fichiers texte des fichiers binaires** : Utiliser un flag (`isBase64`) pour indiquer le type de contenu
|
||||||
|
3. **Calculer le hash sur les bytes bruts** : Ne jamais calculer le hash sur une représentation texte d'un fichier binaire
|
||||||
|
4. **Gérer les erreurs de FileReader** : Toujours implémenter `reader.onerror` pour capturer les erreurs de lecture
|
||||||
|
5. **Utiliser des boucles pour les conversions base64** : Éviter `String.fromCharCode(...array)` qui peut échouer pour les gros fichiers
|
||||||
|
|
||||||
|
## Pages Affectées
|
||||||
|
|
||||||
|
- `signet-dashboard/public/app.js` : Modification de `generateHashFromFile()` (lignes 534-590)
|
||||||
|
- `signet-dashboard/src/server.js` : Modification de la route `/api/hash/generate` (lignes 472-502)
|
||||||
|
- `fixKnowledge/dashboard-hash-generation-error.md` : Documentation (nouveau)
|
||||||
83
fixKnowledge/userwallet-api-relay-fixes.md
Normal file
83
fixKnowledge/userwallet-api-relay-fixes.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Correctifs UserWallet et API Relay
|
||||||
|
|
||||||
|
**Auteur:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## Motivations
|
||||||
|
|
||||||
|
- Corriger la condition de relais dans POST /messages (api-relay) : les messages n’étaient jamais relayés.
|
||||||
|
- Supprimer la duplication du type `MessageBase` dans SyncService et utiliser les types partagés.
|
||||||
|
- Supprimer les fallbacks (retour de `[]` ou `false` en erreur) dans relay.ts et remonter les erreurs.
|
||||||
|
- Implémenter une vraie dérivation clé publique depuis la clé privée à l’import d’identité, et documenter la limite (pas de mnemonic/seed).
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
- **Relais** : On appelait `storage.storeMessage()` puis `if (!storage.hasSeenHash())` pour relayer. Or `storeMessage` marque le hash comme vu, donc la condition était toujours fausse et le relais jamais exécuté.
|
||||||
|
- **MessageBase** : SyncService définissait une interface locale `MessageBase` avec `hash?: string`, alors que le code utilisait `msg.hash.hash_value` (type `Hash`). Incohérence de typage et duplication.
|
||||||
|
- **relay.ts** : Les GET renvoyaient `[]` et les POST `false` en cas d’erreur, ce qui masquait les échecs (fallback interdit).
|
||||||
|
- **importIdentity** : On générait une nouvelle paire, puis on écrasait `privateKey` par la seed fournie tout en gardant la `publicKey` de la paire générée. Aucune dérivation depuis la clé importée.
|
||||||
|
|
||||||
|
## Correctifs
|
||||||
|
|
||||||
|
### 1. api-relay – POST /messages (relay condition)
|
||||||
|
|
||||||
|
**Fichier:** `api-relay/src/routes/messages.ts`
|
||||||
|
|
||||||
|
- Calculer `alreadySeen = storage.hasSeenHash(msg.hash)` **avant** `storage.storeMessage(stored)`.
|
||||||
|
- Appeler `storage.storeMessage(stored)`.
|
||||||
|
- Relayer seulement si `!alreadySeen` : `await relay.relayMessage(msg)` et `stored.relayed = true`.
|
||||||
|
|
||||||
|
### 2. userwallet – SyncService MessageBase
|
||||||
|
|
||||||
|
**Fichier:** `userwallet/src/services/syncService.ts`
|
||||||
|
|
||||||
|
- Importer `MessageBase` depuis `../types/message`.
|
||||||
|
- Supprimer l’interface locale `MessageBase` en fin de fichier.
|
||||||
|
- Utiliser le type partagé pour `validateMessage` et `updateGraph`.
|
||||||
|
|
||||||
|
### 3. userwallet – relay.ts (remonter les erreurs)
|
||||||
|
|
||||||
|
**Fichier:** `userwallet/src/utils/relay.ts`
|
||||||
|
|
||||||
|
- **GET** (`getMessagesChiffres`, `getSignatures`, `getKeys`) : ne plus retourner `[]` en erreur ; `throw new Error(...)` en cas de `!response.ok` ou d’échec fetch, avec message incluant statut et URL du relais.
|
||||||
|
- **POST** (`postMessageChiffre`, `postSignature`, `postKey`) : ne plus retourner `false` ; `throw new Error(...)` en cas d’échec. Types de retour passés à `Promise<void>`.
|
||||||
|
|
||||||
|
**Fichier:** `userwallet/src/components/LoginScreen.tsx`
|
||||||
|
|
||||||
|
- Adapter la boucle de publication : `await postMessageChiffre` / `await postSignature` sans vérifier de booléen ; en cas de throw, le `catch` existant pousse `{ relay, success: false }` et on continue.
|
||||||
|
|
||||||
|
### 4. userwallet – importIdentity (dérivation + limite)
|
||||||
|
|
||||||
|
**Fichiers:** `userwallet/src/utils/crypto.ts`, `userwallet/src/utils/identity.ts`
|
||||||
|
|
||||||
|
- **crypto.ts** : Ajouter `publicKeyFromPrivateKey(privateKeyHex: string): string` qui dérive la clé publique secp256k1 (compressée, hex) depuis la clé privée hex.
|
||||||
|
- **identity.ts** :
|
||||||
|
- Valider que l’entrée est 64 caractères hexadécimaux (32 octets), après trim et suppression d’un préfixe `0x` éventuel.
|
||||||
|
- Utiliser `publicKeyFromPrivateKey(raw)` pour la clé publique.
|
||||||
|
- Stocker `raw` comme `privateKey` et la clé dérivée comme `publicKey`.
|
||||||
|
- Documenter en JSDoc : seul l’import d’une clé privée hex brute est supporté ; mnemonic (BIP39) et dérivation depuis seed ne sont pas implémentés.
|
||||||
|
|
||||||
|
## Évolutions
|
||||||
|
|
||||||
|
- Aucune évolution fonctionnelle au-delà des correctifs ci‑dessus.
|
||||||
|
|
||||||
|
## Pages affectées
|
||||||
|
|
||||||
|
- `api-relay/src/routes/messages.ts`
|
||||||
|
- `userwallet/src/services/syncService.ts`
|
||||||
|
- `userwallet/src/utils/relay.ts`
|
||||||
|
- `userwallet/src/utils/crypto.ts`
|
||||||
|
- `userwallet/src/utils/identity.ts`
|
||||||
|
- `userwallet/src/components/LoginScreen.tsx`
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
- **api-relay** : Redémarrer le serveur après déploiement.
|
||||||
|
- **userwallet** : Rebuild du frontend et déploiement des assets. Les appelants de `relay.ts` (SyncService, LoginScreen) gèrent déjà les erreurs (try/catch) ; les throws sont remontés jusqu’à eux.
|
||||||
|
|
||||||
|
## Modalités d’analyse
|
||||||
|
|
||||||
|
- **Relais** : Poster un message sur un relais avec pairs configurés ; vérifier qu’il est bien relayé vers les pairs (logs, GET sur un pair).
|
||||||
|
- **SyncService** : Vérifier que le type-check et le lint passent ; lancer une sync et s’assurer qu’aucune régression sur la résolution du graphe.
|
||||||
|
- **relay.ts** : En cas de relais injoignable ou réponse non ok, vérifier que l’erreur est bien levée (et non masquée par `[]` / `false`).
|
||||||
|
- **importIdentity** : Importer une clé privée hex valide (64 caractères), vérifier que la clé publique stockée correspond à la dérivation secp256k1. Tester un format invalide (ex. mnemonic) et vérifier le retour `null` et le message en console.
|
||||||
1965
hash_list.txt
1965
hash_list.txt
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
|||||||
2026-01-25T16:04:04.413Z;8701;0000000ad30983fec618eebdfb5da673175f5b54c63ea3ef7d87a1fffb18e33a
|
2026-01-25T23:53:35.210Z;9245;000000057a9ba10877ec7e33c55ab124354dd4cd693992d115068dabdf868264
|
||||||
19
restart-userwallet.sh
Executable file
19
restart-userwallet.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Restart UserWallet service on 192.168.1.105 (vite preview on 3018).
|
||||||
|
# Usage: ./restart-userwallet.sh
|
||||||
|
# Requires: SSH access via proxy (4nk.myftp.biz) to .105.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REMOTE_HOST="192.168.1.105"
|
||||||
|
REMOTE_USER="ncantu"
|
||||||
|
PROXY_HOST="4nk.myftp.biz"
|
||||||
|
|
||||||
|
echo "=== Redémarrage UserWallet sur ${REMOTE_HOST} ==="
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 \
|
||||||
|
-J "${REMOTE_USER}@${PROXY_HOST}" "${REMOTE_USER}@${REMOTE_HOST}" \
|
||||||
|
'sudo systemctl restart userwallet && systemctl status userwallet --no-pager'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Terminé ==="
|
||||||
61
scripts/complete-hash-list-dates.js
Executable file
61
scripts/complete-hash-list-dates.js
Executable file
@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script batch pour compléter les dates manquantes dans hash_list.txt
|
||||||
|
* Ajoute la colonne date (ISO 8601) si elle est absente
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const hashListPath = join(__dirname, '../hash_list.txt');
|
||||||
|
|
||||||
|
if (!existsSync(hashListPath)) {
|
||||||
|
console.error('hash_list.txt not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(hashListPath, 'utf8').trim();
|
||||||
|
if (!content) {
|
||||||
|
console.log('hash_list.txt is empty');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
const outputLines = lines.map((line) => {
|
||||||
|
if (!line.trim()) return line;
|
||||||
|
const parts = line.split(';');
|
||||||
|
|
||||||
|
// Format attendu: hash;txid;blockHeight;confirmations;date
|
||||||
|
if (parts.length === 4) {
|
||||||
|
// Ancien format sans date, ajouter date actuelle
|
||||||
|
updated++;
|
||||||
|
return `${line};${now}`;
|
||||||
|
} else if (parts.length === 5) {
|
||||||
|
// Format avec date, vérifier si date valide
|
||||||
|
const date = parts[4];
|
||||||
|
if (!date || date.trim() === '') {
|
||||||
|
updated++;
|
||||||
|
return `${parts.slice(0, 4).join(';')};${now}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
writeFileSync(hashListPath, outputLines.join('\n'), 'utf8');
|
||||||
|
console.log(`✅ ${updated} ligne(s) mise(s) à jour avec date dans hash_list.txt`);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Toutes les lignes ont déjà une date dans hash_list.txt');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
123
scripts/complete-utxo-list-blocktime.js
Executable file
123
scripts/complete-utxo-list-blocktime.js
Executable file
@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script batch pour compléter les blockTime manquants dans utxo_list.txt
|
||||||
|
* Récupère blockTime depuis RPC pour les UTXOs confirmés sans blockTime
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const utxoListPath = join(__dirname, '../utxo_list.txt');
|
||||||
|
|
||||||
|
// Charger les variables d'environnement
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
config({ path: join(__dirname, '../signet-dashboard/.env') });
|
||||||
|
|
||||||
|
const BITCOIN_RPC_URL = process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
|
||||||
|
const BITCOIN_RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin';
|
||||||
|
const BITCOIN_RPC_PASSWORD = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
||||||
|
const BITCOIN_RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
||||||
|
|
||||||
|
if (!existsSync(utxoListPath)) {
|
||||||
|
console.error('utxo_list.txt not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTransactionBlockTime(txid) {
|
||||||
|
try {
|
||||||
|
const auth = Buffer.from(`${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}`).toString('base64');
|
||||||
|
const rpcUrl = `${BITCOIN_RPC_URL}/wallet/${BITCOIN_RPC_WALLET}`;
|
||||||
|
|
||||||
|
const response = await fetch(rpcUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '1.0',
|
||||||
|
id: 'gettx',
|
||||||
|
method: 'gettransaction',
|
||||||
|
params: [txid],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.result.blocktime || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(utxoListPath, 'utf8').trim();
|
||||||
|
if (!content) {
|
||||||
|
console.log('utxo_list.txt is empty');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let updated = 0;
|
||||||
|
let processed = 0;
|
||||||
|
const total = lines.length;
|
||||||
|
|
||||||
|
console.log(`📊 Traitement de ${total} lignes...`);
|
||||||
|
|
||||||
|
const outputLines = await Promise.all(lines.map(async (line) => {
|
||||||
|
if (!line.trim()) return line;
|
||||||
|
const parts = line.split(';');
|
||||||
|
|
||||||
|
// Format attendu: category;txid;vout;amount;confirmations;isAnchorChange;blockTime
|
||||||
|
if (parts.length >= 6) {
|
||||||
|
const confirmations = parseInt(parts[4], 10) || 0;
|
||||||
|
const blockTime = parts.length >= 7 ? parts[6] : '';
|
||||||
|
|
||||||
|
// Si confirmé mais blockTime manquant, récupérer depuis RPC
|
||||||
|
if (confirmations > 0 && (!blockTime || blockTime.trim() === '')) {
|
||||||
|
const txid = parts[1];
|
||||||
|
processed++;
|
||||||
|
|
||||||
|
if (processed % 10 === 0) {
|
||||||
|
console.log(`⏳ Traitement: ${processed}/${total}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const txBlockTime = await getTransactionBlockTime(txid);
|
||||||
|
if (txBlockTime) {
|
||||||
|
updated++;
|
||||||
|
// Reconstruire la ligne avec blockTime
|
||||||
|
const newParts = [...parts];
|
||||||
|
if (newParts.length === 6) {
|
||||||
|
newParts.push(txBlockTime.toString());
|
||||||
|
} else {
|
||||||
|
newParts[6] = txBlockTime.toString();
|
||||||
|
}
|
||||||
|
return newParts.join(';');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (updated > 0) {
|
||||||
|
writeFileSync(utxoListPath, outputLines.join('\n'), 'utf8');
|
||||||
|
console.log(`✅ ${updated} ligne(s) mise(s) à jour avec blockTime dans utxo_list.txt`);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Toutes les lignes ont déjà un blockTime dans utxo_list.txt');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
157
scripts/diagnose-bloc-rewards.js
Executable file
157
scripts/diagnose-bloc-rewards.js
Executable file
@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script de diagnostic pour comprendre pourquoi les Bloc Rewards affichent ~4700 BTC au lieu de 50 BTC
|
||||||
|
* Vérifie : listunspent, getrawtransaction, blockheight, subsidy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
config({ path: join(__dirname, '../signet-dashboard/.env') });
|
||||||
|
|
||||||
|
const BITCOIN_RPC_URL = process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
|
||||||
|
const BITCOIN_RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin';
|
||||||
|
const BITCOIN_RPC_PASSWORD = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
||||||
|
const BITCOIN_RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
||||||
|
|
||||||
|
const auth = Buffer.from(`${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}`).toString('base64');
|
||||||
|
const rpcUrl = `${BITCOIN_RPC_URL}/wallet/${BITCOIN_RPC_WALLET}`;
|
||||||
|
|
||||||
|
async function rpcCall(method, params = []) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(rpcUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '1.0',
|
||||||
|
id: method,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`RPC error: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error calling ${method}:`, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diagnoseBlocRewards() {
|
||||||
|
console.log('🔍 Diagnostic Bloc Rewards - Pourquoi ~4700 BTC au lieu de 50 BTC?\n');
|
||||||
|
|
||||||
|
// 1. Lire les Bloc Rewards depuis utxo_list.txt
|
||||||
|
const utxoListPath = join(__dirname, '../utxo_list.txt');
|
||||||
|
if (!existsSync(utxoListPath)) {
|
||||||
|
console.error('❌ utxo_list.txt not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(utxoListPath, 'utf8').trim();
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const blocRewards = lines
|
||||||
|
.filter(line => line.trim().startsWith('bloc_rewards;'))
|
||||||
|
.slice(0, 5); // Prendre les 5 premiers
|
||||||
|
|
||||||
|
if (blocRewards.length === 0) {
|
||||||
|
console.log('⚠️ Aucun Bloc Reward trouvé dans utxo_list.txt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Analyse de ${blocRewards.length} Bloc Rewards depuis utxo_list.txt:\n`);
|
||||||
|
|
||||||
|
// 2. Vérifier blockchain info (hauteur actuelle)
|
||||||
|
console.log('1️⃣ Informations blockchain:');
|
||||||
|
const blockchainInfo = await rpcCall('getblockchaininfo');
|
||||||
|
if (blockchainInfo) {
|
||||||
|
console.log(` Hauteur actuelle: ${blockchainInfo.blocks}`);
|
||||||
|
console.log(` Chain: ${blockchainInfo.chain}`);
|
||||||
|
console.log(` Subsidy attendu (blocs 0-209999): 50 BTC\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Analyser chaque Bloc Reward
|
||||||
|
for (let i = 0; i < blocRewards.length; i++) {
|
||||||
|
const line = blocRewards[i];
|
||||||
|
const parts = line.split(';');
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
|
||||||
|
const txid = parts[1];
|
||||||
|
const amount = parseFloat(parts[3]);
|
||||||
|
const confirmations = parseInt(parts[4], 10) || 0;
|
||||||
|
|
||||||
|
console.log(`\n${i + 1}. Transaction: ${txid}`);
|
||||||
|
console.log(` Montant dans fichier: ${amount} BTC`);
|
||||||
|
|
||||||
|
// 4. Vérifier listunspent
|
||||||
|
const unspent = await rpcCall('listunspent', [1]);
|
||||||
|
if (unspent) {
|
||||||
|
const utxo = unspent.find(u => u.txid === txid && u.vout === 0);
|
||||||
|
if (utxo) {
|
||||||
|
console.log(` Montant listunspent: ${utxo.amount} BTC`);
|
||||||
|
console.log(` Confirmations listunspent: ${utxo.confirmations}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ UTXO non trouvé dans listunspent (peut être dépensé)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Vérifier getrawtransaction
|
||||||
|
const rawTx = await rpcCall('getrawtransaction', [txid, true]);
|
||||||
|
if (rawTx) {
|
||||||
|
console.log(` Blockheight: ${rawTx.blockheight || 'non confirmé'}`);
|
||||||
|
console.log(` Blocktime: ${rawTx.blocktime ? new Date(rawTx.blocktime * 1000).toISOString() : 'N/A'}`);
|
||||||
|
|
||||||
|
if (rawTx.vout && rawTx.vout.length > 0) {
|
||||||
|
const vout0 = rawTx.vout[0];
|
||||||
|
console.log(` Vout[0].value: ${vout0.value} BTC`);
|
||||||
|
console.log(` Vout[0].scriptPubKey.type: ${vout0.scriptPubKey?.type || 'N/A'}`);
|
||||||
|
|
||||||
|
// Vérifier si coinbase
|
||||||
|
if (rawTx.vin && rawTx.vin.length === 1 && rawTx.vin[0].coinbase) {
|
||||||
|
console.log(` ✅ Transaction coinbase détectée`);
|
||||||
|
console.log(` Coinbase: ${rawTx.vin[0].coinbase}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Calculer subsidy attendu
|
||||||
|
if (rawTx.blockheight !== null && rawTx.blockheight !== undefined) {
|
||||||
|
const height = rawTx.blockheight;
|
||||||
|
if (height < 210000) {
|
||||||
|
const expectedSubsidy = 50;
|
||||||
|
const fees = amount - expectedSubsidy;
|
||||||
|
console.log(` Subsidy attendu (hauteur ${height}): ${expectedSubsidy} BTC`);
|
||||||
|
console.log(` Frais calculés (montant - subsidy): ${fees.toFixed(8)} BTC`);
|
||||||
|
if (Math.abs(fees) > 1) {
|
||||||
|
console.log(` ⚠️ ÉCART IMPORTANT: ${fees.toFixed(2)} BTC de frais (anormal pour un bloc)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Bloc après 210000, subsidy devrait être réduit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n\n📝 Conclusion:');
|
||||||
|
console.log(' - Si montants listunspent/getrawtransaction = montants fichier → source correcte');
|
||||||
|
console.log(' - Si montants >> 50 BTC → signet custom avec subsidy différent ou bug nœud');
|
||||||
|
console.log(' - Vérifier chain params du signet (subsidy, halving)');
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnoseBlocRewards().catch(console.error);
|
||||||
@ -71,9 +71,6 @@
|
|||||||
<span id="available-for-anchor-value">-</span>
|
<span id="available-for-anchor-value">-</span>
|
||||||
<span id="available-for-anchor-spinner" class="spinner" style="display: none;">⏳</span>
|
<span id="available-for-anchor-spinner" class="spinner" style="display: none;">⏳</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="sub-value" id="confirmed-available-for-anchor" style="font-size: 0.9em; color: #666; margin-top: 5px;">
|
|
||||||
<span id="confirmed-available-for-anchor-value">-</span> UTXOs confirmés
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Nombre de Pairs</h3>
|
<h3>Nombre de Pairs</h3>
|
||||||
|
|||||||
@ -5,17 +5,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Rejoindre le Réseau Signet Custom - Bitcoin Ancrage</title>
|
<title>Rejoindre le Réseau Signet Custom - Bitcoin Ancrage</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// Vérifier que la bibliothèque QRCode est chargée
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
if (typeof QRCode === 'undefined') {
|
|
||||||
console.error('Bibliothèque QRCode non chargée');
|
|
||||||
} else {
|
|
||||||
console.log('Bibliothèque QRCode chargée');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<style>
|
<style>
|
||||||
.join-section {
|
.join-section {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
@ -104,19 +93,19 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-code-container {
|
.nostr-profile-link {
|
||||||
margin: 30px 0;
|
display: inline-block;
|
||||||
display: flex;
|
margin-top: 15px;
|
||||||
justify-content: center;
|
padding: 10px 20px;
|
||||||
align-items: center;
|
background: var(--primary-color);
|
||||||
flex-direction: column;
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: background 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#qrcode {
|
.nostr-profile-link:hover {
|
||||||
background: white;
|
background: #e0820d;
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wallet-section {
|
.wallet-section {
|
||||||
@ -238,7 +227,7 @@ addnode=anchorage.certificator.4nkweb.com:38333</div>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Section Paiement -->
|
<!-- Section Paiement Wallet de Mining -->
|
||||||
<section class="join-section">
|
<section class="join-section">
|
||||||
<div class="payment-section">
|
<div class="payment-section">
|
||||||
<h2>💳 Accès au Wallet de Mining</h2>
|
<h2>💳 Accès au Wallet de Mining</h2>
|
||||||
@ -246,31 +235,68 @@ addnode=anchorage.certificator.4nkweb.com:38333</div>
|
|||||||
|
|
||||||
<div class="payment-amount">0,0065 BTC</div>
|
<div class="payment-amount">0,0065 BTC</div>
|
||||||
|
|
||||||
<p>à l'adresse suivante :</p>
|
<p>Envoyez le paiement via Nostr à :</p>
|
||||||
|
|
||||||
<div class="payment-address" id="payment-address">bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y</div>
|
<div class="payment-address" id="wallet-npub">npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu</div>
|
||||||
|
|
||||||
<button class="copy-button" onclick="copyAddress()">📋 Copier l'adresse</button>
|
<button class="copy-button" onclick="copyNpub('wallet-npub')">📋 Copier la npub</button>
|
||||||
|
|
||||||
<div class="qr-code-container">
|
<a href="https://yakihonne.com/profile/fancy-wallaby-90@rizful.com" target="_blank" rel="noopener noreferrer" class="nostr-profile-link">
|
||||||
<div id="qrcode"></div>
|
🔗 Voir le profil Nostr
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<div class="wallet-checkbox">
|
<div class="wallet-checkbox">
|
||||||
<input type="checkbox" id="wallet-request" onchange="updatePaymentMessage()">
|
<input type="checkbox" id="wallet-request" onchange="updatePaymentMessage('wallet')">
|
||||||
<label for="wallet-request">Je souhaite recevoir le wallet de mining après le paiement</label>
|
<label for="wallet-request">Je souhaite recevoir le wallet de mining après le paiement</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box" id="payment-info">
|
<div class="info-box" id="wallet-payment-info">
|
||||||
<p><strong>Instructions :</strong></p>
|
<p><strong>Instructions :</strong></p>
|
||||||
<p>1. Effectuez le paiement de 0,0065 BTC à l'adresse ci-dessus</p>
|
<p>1. Effectuez le paiement de 0,0065 BTC via Nostr à la npub ci-dessus</p>
|
||||||
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir le wallet de mining</p>
|
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir le wallet de mining</p>
|
||||||
<p>3. Après confirmation du paiement, vous recevrez les informations nécessaires</p>
|
<p>3. Après confirmation du paiement, vous recevrez le wallet de mining sur Nostr</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="success-message" id="payment-success">
|
<div class="success-message" id="wallet-payment-success">
|
||||||
<p><strong>✅ Paiement reçu !</strong></p>
|
<p><strong>✅ Paiement reçu !</strong></p>
|
||||||
<p>Votre demande a été enregistrée. Vous recevrez le wallet de mining sous peu.</p>
|
<p>Votre demande a été enregistrée. Vous recevrez le wallet de mining sur Nostr sous peu.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section Paiement Clé API -->
|
||||||
|
<section class="join-section">
|
||||||
|
<div class="payment-section">
|
||||||
|
<h2>🔑 Accès à une Clé API</h2>
|
||||||
|
<p>Pour recevoir une clé API permettant d'utiliser les services d'ancrage et de filigrane, effectuez un paiement de :</p>
|
||||||
|
|
||||||
|
<div class="payment-amount">0,0065 BTC</div>
|
||||||
|
|
||||||
|
<p>Envoyez le paiement via Nostr à :</p>
|
||||||
|
|
||||||
|
<div class="payment-address" id="api-npub">npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu</div>
|
||||||
|
|
||||||
|
<button class="copy-button" onclick="copyNpub('api-npub')">📋 Copier la npub</button>
|
||||||
|
|
||||||
|
<a href="https://yakihonne.com/profile/fancy-wallaby-90@rizful.com" target="_blank" rel="noopener noreferrer" class="nostr-profile-link">
|
||||||
|
🔗 Voir le profil Nostr
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="wallet-checkbox">
|
||||||
|
<input type="checkbox" id="api-request" onchange="updatePaymentMessage('api')">
|
||||||
|
<label for="api-request">Je souhaite recevoir la clé API après le paiement</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box" id="api-payment-info">
|
||||||
|
<p><strong>Instructions :</strong></p>
|
||||||
|
<p>1. Effectuez le paiement de 0,0065 BTC via Nostr à la npub ci-dessus</p>
|
||||||
|
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir la clé API</p>
|
||||||
|
<p>3. Après confirmation du paiement, vous recevrez la clé API sur Nostr</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="success-message" id="api-payment-success">
|
||||||
|
<p><strong>✅ Paiement reçu !</strong></p>
|
||||||
|
<p>Votre demande a été enregistrée. Vous recevrez la clé API sur Nostr sous peu.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -292,11 +318,12 @@ addnode=anchorage.certificator.4nkweb.com:38333</div>
|
|||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p><strong>Que se passe-t-il après le paiement ?</strong></p>
|
<p><strong>Que se passe-t-il après le paiement ?</strong></p>
|
||||||
<p>Une fois le paiement confirmé (généralement après 1 confirmation), vous recevrez par email :</p>
|
<p>Une fois le paiement confirmé, vous recevrez sur Nostr :</p>
|
||||||
<ul style="margin-left: 20px; margin-top: 10px;">
|
<ul style="margin-left: 20px; margin-top: 10px;">
|
||||||
<li>Les fichiers de configuration complets</li>
|
<li>Les fichiers de configuration complets</li>
|
||||||
<li>La clé privée du signet (si vous avez coché la case)</li>
|
<li>La clé privée du signet (si vous avez coché la case pour le wallet de mining)</li>
|
||||||
<li>Les instructions détaillées pour démarrer votre nœud</li>
|
<li>La clé API (si vous avez coché la case pour la clé API)</li>
|
||||||
|
<li>Les instructions détaillées pour démarrer votre nœud ou utiliser l'API</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -311,75 +338,18 @@ addnode=anchorage.certificator.4nkweb.com:38333</div>
|
|||||||
<footer>
|
<footer>
|
||||||
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>
|
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>
|
||||||
<a href="https://git.4nkweb.com/4nk/anchorage_layer_simple.git" target="_blank" rel="noopener noreferrer" class="git-link" title="Voir le code source sur Git">
|
<a href="https://git.4nkweb.com/4nk/anchorage_layer_simple.git" target="_blank" rel="noopener noreferrer" class="git-link" title="Voir le code source sur Git">
|
||||||
<svg class="git-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="git-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.083 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.884.721-2.599 0-.72-.719-.72-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.011L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const PAYMENT_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y';
|
const NOSTR_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu';
|
||||||
|
const NOSTR_PROFILE_URL = 'https://yakihonne.com/profile/fancy-wallaby-90@rizful.com';
|
||||||
const PAYMENT_AMOUNT = 0.0065;
|
const PAYMENT_AMOUNT = 0.0065;
|
||||||
|
|
||||||
// Générer le QR code au chargement de la page
|
|
||||||
function initQRCode() {
|
|
||||||
// Attendre que le DOM soit prêt et que la bibliothèque soit chargée
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', generateQRCode);
|
|
||||||
} else {
|
|
||||||
// Si le DOM est déjà chargé, attendre un peu pour la bibliothèque
|
|
||||||
setTimeout(generateQRCode, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateQRCode() {
|
|
||||||
const qrElement = document.getElementById('qrcode');
|
|
||||||
if (!qrElement) {
|
|
||||||
console.error('Élément QR code non trouvé');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier que la bibliothèque QRCode est disponible
|
|
||||||
if (typeof QRCode === 'undefined') {
|
|
||||||
console.error('Bibliothèque QRCode non disponible, réessai...');
|
|
||||||
setTimeout(generateQRCode, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentURI = `bitcoin:${PAYMENT_ADDRESS}?amount=${PAYMENT_AMOUNT}`;
|
|
||||||
|
|
||||||
// Vider l'élément avant de générer le QR code
|
|
||||||
qrElement.innerHTML = '';
|
|
||||||
|
|
||||||
// Utiliser toDataURL pour générer une image
|
|
||||||
QRCode.toDataURL(paymentURI, {
|
|
||||||
width: 300,
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: '#000000',
|
|
||||||
light: '#FFFFFF'
|
|
||||||
}
|
|
||||||
}, (error, url) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('Erreur lors de la génération du QR code:', error);
|
|
||||||
qrElement.innerHTML = '<p style="color: red; padding: 20px;">Erreur lors de la génération du QR code. Veuillez recharger la page.</p>';
|
|
||||||
} else {
|
|
||||||
// Créer une image avec le data URL
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = url;
|
|
||||||
img.alt = 'QR Code pour paiement Bitcoin';
|
|
||||||
img.style.display = 'block';
|
|
||||||
img.style.margin = '0 auto';
|
|
||||||
qrElement.appendChild(img);
|
|
||||||
console.log('QR code généré avec succès');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialiser le QR code
|
|
||||||
initQRCode();
|
|
||||||
|
|
||||||
function copyConfig() {
|
function copyConfig() {
|
||||||
const configText = document.getElementById('bitcoin-config')?.textContent || '';
|
const configText = document.getElementById('bitcoin-config')?.textContent || '';
|
||||||
navigator.clipboard.writeText(configText).then(() => {
|
navigator.clipboard.writeText(configText).then(() => {
|
||||||
@ -397,8 +367,10 @@ addnode=anchorage.certificator.4nkweb.com:38333</div>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyAddress() {
|
function copyNpub(elementId) {
|
||||||
navigator.clipboard.writeText(PAYMENT_ADDRESS).then(() => {
|
const npubElement = document.getElementById(elementId);
|
||||||
|
const npub = npubElement?.textContent || NOSTR_NPUB;
|
||||||
|
navigator.clipboard.writeText(npub).then(() => {
|
||||||
const button = event?.target;
|
const button = event?.target;
|
||||||
if (button) {
|
if (button) {
|
||||||
const originalText = button.textContent;
|
const originalText = button.textContent;
|
||||||
@ -413,25 +385,48 @@ addnode=anchorage.certificator.4nkweb.com:38333</div>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePaymentMessage() {
|
function updatePaymentMessage(type) {
|
||||||
const checkbox = document.getElementById('wallet-request');
|
if (type === 'wallet') {
|
||||||
const paymentInfo = document.getElementById('payment-info');
|
const checkbox = document.getElementById('wallet-request');
|
||||||
|
const paymentInfo = document.getElementById('wallet-payment-info');
|
||||||
|
|
||||||
if (checkbox && paymentInfo) {
|
if (checkbox && paymentInfo) {
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
paymentInfo.innerHTML = `
|
paymentInfo.innerHTML = `
|
||||||
<p><strong>Instructions :</strong></p>
|
<p><strong>Instructions :</strong></p>
|
||||||
<p>1. Effectuez le paiement de 0,0065 BTC à l'adresse ci-dessus</p>
|
<p>1. Effectuez le paiement de 0,0065 BTC via Nostr à la npub ci-dessus</p>
|
||||||
<p>2. ✅ Vous recevrez le wallet de mining après confirmation du paiement</p>
|
<p>2. ✅ Vous recevrez le wallet de mining après confirmation du paiement</p>
|
||||||
<p>3. Les informations vous seront envoyées par email</p>
|
<p>3. Le wallet vous sera envoyé sur Nostr</p>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
paymentInfo.innerHTML = `
|
paymentInfo.innerHTML = `
|
||||||
<p><strong>Instructions :</strong></p>
|
<p><strong>Instructions :</strong></p>
|
||||||
<p>1. Effectuez le paiement de 0,0065 BTC à l'adresse ci-dessus</p>
|
<p>1. Effectuez le paiement de 0,0065 BTC via Nostr à la npub ci-dessus</p>
|
||||||
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir le wallet de mining</p>
|
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir le wallet de mining</p>
|
||||||
<p>3. Après confirmation du paiement, vous recevrez les informations nécessaires</p>
|
<p>3. Après confirmation du paiement, vous recevrez le wallet de mining sur Nostr</p>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === 'api') {
|
||||||
|
const checkbox = document.getElementById('api-request');
|
||||||
|
const paymentInfo = document.getElementById('api-payment-info');
|
||||||
|
|
||||||
|
if (checkbox && paymentInfo) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
paymentInfo.innerHTML = `
|
||||||
|
<p><strong>Instructions :</strong></p>
|
||||||
|
<p>1. Effectuez le paiement de 0,0065 BTC via Nostr à la npub ci-dessus</p>
|
||||||
|
<p>2. ✅ Vous recevrez la clé API après confirmation du paiement</p>
|
||||||
|
<p>3. La clé API vous sera envoyée sur Nostr</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
paymentInfo.innerHTML = `
|
||||||
|
<p><strong>Instructions :</strong></p>
|
||||||
|
<p>1. Effectuez le paiement de 0,0065 BTC via Nostr à la npub ci-dessus</p>
|
||||||
|
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir la clé API</p>
|
||||||
|
<p>3. Après confirmation du paiement, vous recevrez la clé API sur Nostr</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -820,6 +820,10 @@
|
|||||||
<span><strong>Nombre :</strong> ${fees.length.toLocaleString('fr-FR')}</span>
|
<span><strong>Nombre :</strong> ${fees.length.toLocaleString('fr-FR')}</span>
|
||||||
<span><strong>Total des frais :</strong> ${formatBTC(totalFees)} (${totalFeesSats.toLocaleString('fr-FR')} ✅)</span>
|
<span><strong>Total des frais :</strong> ${formatBTC(totalFees)} (${totalFeesSats.toLocaleString('fr-FR')} ✅)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="update-fees-button" class="refresh-button" onclick="updateFeesFromAnchors()" style="margin-top: 10px;">
|
||||||
|
<span id="update-fees-spinner" style="display: none;">⏳ </span>
|
||||||
|
Récupérer les frais depuis les ancrages
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
@ -949,6 +953,49 @@
|
|||||||
button.textContent = originalText;
|
button.textContent = originalText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateFeesFromAnchors() {
|
||||||
|
const button = document.getElementById('update-fees-button');
|
||||||
|
const spinner = document.getElementById('update-fees-spinner');
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
spinner.style.display = 'inline';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/utxo/fees/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(`Frais mis à jour!\n\nNouveaux frais récupérés: ${result.newFees}\nTotal frais: ${result.totalFees}\nTransactions traitées: ${result.processed}`);
|
||||||
|
// Recharger la liste après mise à jour
|
||||||
|
setTimeout(() => {
|
||||||
|
loadUtxoList();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Mise à jour des frais échouée');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating fees from anchors:', error);
|
||||||
|
alert(`Erreur lors de la mise à jour des frais: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<footer>
|
<footer>
|
||||||
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>
|
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>
|
||||||
|
|||||||
@ -148,7 +148,7 @@ class BitcoinRPC {
|
|||||||
* Obtient la liste des hash ancrés avec leurs transactions
|
* Obtient la liste des hash ancrés avec leurs transactions
|
||||||
* Lit directement depuis hash_list.txt et ne complète que les nouveaux blocs si nécessaire
|
* Lit directement depuis hash_list.txt et ne complète que les nouveaux blocs si nécessaire
|
||||||
* Format du cache: <date>;<hauteur du dernier bloc>;<hash du dernier bloc>
|
* Format du cache: <date>;<hauteur du dernier bloc>;<hash du dernier bloc>
|
||||||
* Format du fichier de sortie: <hash>;<txid>;<block_height>;<confirmations>
|
* Format du fichier de sortie: <hash>;<txid>;<block_height>;<confirmations>;<date>
|
||||||
* @returns {Promise<Array<Object>>} Liste des hash avec leurs transactions
|
* @returns {Promise<Array<Object>>} Liste des hash avec leurs transactions
|
||||||
*/
|
*/
|
||||||
async getHashList() {
|
async getHashList() {
|
||||||
@ -168,13 +168,15 @@ class BitcoinRPC {
|
|||||||
const lines = existingContent.split('\n');
|
const lines = existingContent.split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
const [hash, txid, blockHeight, confirmations] = line.split(';');
|
const parts = line.split(';');
|
||||||
|
const [hash, txid, blockHeight, confirmations, date] = parts;
|
||||||
if (hash && txid) {
|
if (hash && txid) {
|
||||||
hashList.push({
|
hashList.push({
|
||||||
hash,
|
hash,
|
||||||
txid,
|
txid,
|
||||||
blockHeight: blockHeight ? parseInt(blockHeight, 10) : null,
|
blockHeight: blockHeight ? parseInt(blockHeight, 10) : null,
|
||||||
confirmations: confirmations ? parseInt(confirmations, 10) : 0,
|
confirmations: confirmations ? parseInt(confirmations, 10) : 0,
|
||||||
|
date: date || new Date().toISOString(), // Date actuelle si manquante
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -274,6 +276,7 @@ class BitcoinRPC {
|
|||||||
txid: tx.txid,
|
txid: tx.txid,
|
||||||
blockHeight: height,
|
blockHeight: height,
|
||||||
confirmations,
|
confirmations,
|
||||||
|
date: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break; // Un seul hash par transaction
|
break; // Un seul hash par transaction
|
||||||
@ -292,9 +295,10 @@ class BitcoinRPC {
|
|||||||
const cacheContent = `${now};${height};${blockHash}`;
|
const cacheContent = `${now};${height};${blockHash}`;
|
||||||
writeFileSync(cachePath, cacheContent, 'utf8');
|
writeFileSync(cachePath, cacheContent, 'utf8');
|
||||||
|
|
||||||
// Écrire le fichier de sortie
|
// Écrire le fichier de sortie avec date
|
||||||
|
const now = new Date().toISOString();
|
||||||
const outputLines = hashList.map((item) =>
|
const outputLines = hashList.map((item) =>
|
||||||
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0}`
|
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
|
||||||
);
|
);
|
||||||
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
|
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
|
||||||
logger.debug('Hash list cache updated', { height, count: hashList.length });
|
logger.debug('Hash list cache updated', { height, count: hashList.length });
|
||||||
@ -309,9 +313,10 @@ class BitcoinRPC {
|
|||||||
const cacheContent = `${now};${currentHeight};${currentBlockHash}`;
|
const cacheContent = `${now};${currentHeight};${currentBlockHash}`;
|
||||||
writeFileSync(cachePath, cacheContent, 'utf8');
|
writeFileSync(cachePath, cacheContent, 'utf8');
|
||||||
|
|
||||||
// Écrire le fichier de sortie final
|
// Écrire le fichier de sortie final avec date
|
||||||
|
const now = new Date().toISOString();
|
||||||
const outputLines = hashList.map((item) =>
|
const outputLines = hashList.map((item) =>
|
||||||
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0}`
|
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
|
||||||
);
|
);
|
||||||
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
|
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
|
||||||
logger.info('Hash list saved', { currentHeight, count: hashList.length });
|
logger.info('Hash list saved', { currentHeight, count: hashList.length });
|
||||||
@ -538,6 +543,38 @@ class BitcoinRPC {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charger les frais depuis fees_list.txt si disponible
|
||||||
|
const feesListPath = join(__dirname, '../../fees_list.txt');
|
||||||
|
if (existsSync(feesListPath)) {
|
||||||
|
try {
|
||||||
|
const feesContent = readFileSync(feesListPath, 'utf8').trim();
|
||||||
|
if (feesContent) {
|
||||||
|
const feesLines = feesContent.split('\n');
|
||||||
|
for (const line of feesLines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
|
||||||
|
const parts = line.split(';');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const feeObj = {
|
||||||
|
txid: parts[0],
|
||||||
|
fee: parseFloat(parts[1]) || 0,
|
||||||
|
fee_sats: parseInt(parts[2], 10) || 0,
|
||||||
|
blockHeight: parts[3] ? parseInt(parts[3], 10) : null,
|
||||||
|
blockTime: parts[4] ? parseInt(parts[4], 10) : null,
|
||||||
|
confirmations: parts[5] ? parseInt(parts[5], 10) : 0,
|
||||||
|
changeAddress: parts[6] || null,
|
||||||
|
changeAmount: parts[7] ? parseFloat(parts[7]) : null,
|
||||||
|
};
|
||||||
|
fees.push(feeObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error reading fees_list.txt', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculer availableForAnchor
|
// Calculer availableForAnchor
|
||||||
const availableForAnchor = anchors.filter(u => !u.isSpentOnchain && !u.isLockedInMutex && (u.confirmations || 0) >= 1).length;
|
const availableForAnchor = anchors.filter(u => !u.isSpentOnchain && !u.isLockedInMutex && (u.confirmations || 0) >= 1).length;
|
||||||
const confirmedAvailableForAnchor = anchors.filter(u => !u.isSpentOnchain && !u.isLockedInMutex && (u.confirmations || 0) >= 6).length;
|
const confirmedAvailableForAnchor = anchors.filter(u => !u.isSpentOnchain && !u.isLockedInMutex && (u.confirmations || 0) >= 6).length;
|
||||||
@ -1022,6 +1059,41 @@ class BitcoinRPC {
|
|||||||
return b.blockHeight - a.blockHeight;
|
return b.blockHeight - a.blockHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Charger les frais depuis fees_list.txt si disponible
|
||||||
|
const feesListPath = join(__dirname, '../../fees_list.txt');
|
||||||
|
if (existsSync(feesListPath)) {
|
||||||
|
try {
|
||||||
|
const feesContent = readFileSync(feesListPath, 'utf8').trim();
|
||||||
|
if (feesContent) {
|
||||||
|
const feesLines = feesContent.split('\n');
|
||||||
|
for (const line of feesLines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
|
||||||
|
const parts = line.split(';');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const feeObj = {
|
||||||
|
txid: parts[0],
|
||||||
|
fee: parseFloat(parts[1]) || 0,
|
||||||
|
fee_sats: parseInt(parts[2], 10) || 0,
|
||||||
|
blockHeight: parts[3] ? parseInt(parts[3], 10) : null,
|
||||||
|
blockTime: parts[4] ? parseInt(parts[4], 10) : null,
|
||||||
|
confirmations: parts[5] ? parseInt(parts[5], 10) : 0,
|
||||||
|
changeAddress: parts[6] || null,
|
||||||
|
changeAmount: parts[7] ? parseFloat(parts[7]) : null,
|
||||||
|
};
|
||||||
|
// Vérifier si pas déjà dans fees (éviter doublons)
|
||||||
|
if (!fees.find(f => f.txid === feeObj.txid)) {
|
||||||
|
fees.push(feeObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error reading fees_list.txt', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('UTXO list saved', {
|
logger.info('UTXO list saved', {
|
||||||
blocRewards: blocRewards.length,
|
blocRewards: blocRewards.length,
|
||||||
anchors: anchors.length,
|
anchors: anchors.length,
|
||||||
@ -1437,6 +1509,203 @@ class BitcoinRPC {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour les frais depuis les transactions d'ancrage
|
||||||
|
* Récupère les frais depuis OP_RETURN des transactions d'ancrage et les stocke dans fees_list.txt
|
||||||
|
* @param {number} sinceBlockHeight - Hauteur de bloc à partir de laquelle récupérer (optionnel, depuis dernier frais du fichier)
|
||||||
|
* @returns {Promise<Object>} Résultat avec nombre de frais récupérés
|
||||||
|
*/
|
||||||
|
async updateFeesFromAnchors(sinceBlockHeight = null) {
|
||||||
|
try {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const feesListPath = join(__dirname, '../../fees_list.txt');
|
||||||
|
const utxoListPath = join(__dirname, '../../utxo_list.txt');
|
||||||
|
|
||||||
|
// Lire les frais existants
|
||||||
|
const existingFees = new Map();
|
||||||
|
if (existsSync(feesListPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(feesListPath, 'utf8').trim();
|
||||||
|
if (content) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
|
||||||
|
const parts = line.split(';');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const txid = parts[0];
|
||||||
|
existingFees.set(txid, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error reading fees_list.txt', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déterminer depuis quelle hauteur récupérer
|
||||||
|
let startHeight = sinceBlockHeight;
|
||||||
|
if (!startHeight) {
|
||||||
|
// Trouver la hauteur maximale des frais existants
|
||||||
|
let maxHeight = 0;
|
||||||
|
for (const line of existingFees.values()) {
|
||||||
|
const parts = line.split(';');
|
||||||
|
if (parts.length >= 4 && parts[3]) {
|
||||||
|
const height = parseInt(parts[3], 10);
|
||||||
|
if (!isNaN(height) && height > maxHeight) {
|
||||||
|
maxHeight = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startHeight = maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire les ancrages depuis utxo_list.txt pour obtenir les txids
|
||||||
|
const anchorTxids = new Set();
|
||||||
|
if (existsSync(utxoListPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(utxoListPath, 'utf8').trim();
|
||||||
|
if (content) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
const parts = line.split(';');
|
||||||
|
if (parts.length >= 2 && parts[0] === 'ancrages') {
|
||||||
|
const txid = parts[1];
|
||||||
|
anchorTxids.add(txid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error reading utxo_list.txt for anchors', { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les frais depuis les transactions d'ancrage
|
||||||
|
const newFees = [];
|
||||||
|
let processed = 0;
|
||||||
|
const totalAnchors = anchorTxids.size;
|
||||||
|
|
||||||
|
for (const txid of anchorTxids) {
|
||||||
|
// Ignorer si déjà dans les frais existants
|
||||||
|
if (existingFees.has(txid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawTx = await this.client.getRawTransaction(txid, true);
|
||||||
|
if (!rawTx || !rawTx.vout) continue;
|
||||||
|
|
||||||
|
let onchainFeeAmount = null;
|
||||||
|
let blockHeight = null;
|
||||||
|
let blockTime = null;
|
||||||
|
let changeAddress = null;
|
||||||
|
let changeAmount = null;
|
||||||
|
|
||||||
|
// Extraire les métadonnées depuis OP_RETURN
|
||||||
|
for (const output of rawTx.vout) {
|
||||||
|
if (output.scriptPubKey && output.scriptPubKey.hex) {
|
||||||
|
const scriptHex = output.scriptPubKey.hex;
|
||||||
|
const anchorPrefixHex = Buffer.from('ANCHOR:', 'utf8').toString('hex');
|
||||||
|
|
||||||
|
if (scriptHex.includes(anchorPrefixHex)) {
|
||||||
|
try {
|
||||||
|
const hashLengthHex = 64;
|
||||||
|
const separatorHex = Buffer.from('|', 'utf8').toString('hex');
|
||||||
|
const anchorPos = scriptHex.indexOf(anchorPrefixHex);
|
||||||
|
if (anchorPos !== -1) {
|
||||||
|
const afterHashPos = anchorPos + anchorPrefixHex.length + hashLengthHex;
|
||||||
|
const separatorPos = scriptHex.indexOf(separatorHex, afterHashPos);
|
||||||
|
if (separatorPos !== -1) {
|
||||||
|
const metadataHex = scriptHex.substring(separatorPos + separatorHex.length);
|
||||||
|
const metadataBuffer = Buffer.from(metadataHex, 'hex');
|
||||||
|
const metadataString = metadataBuffer.toString('utf8');
|
||||||
|
const parts = metadataString.split('|');
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.startsWith('CHANGE:')) {
|
||||||
|
const changeData = part.substring(7);
|
||||||
|
const changeParts = changeData.split(':');
|
||||||
|
if (changeParts.length === 2 && changeParts[0] !== 'none') {
|
||||||
|
changeAddress = changeParts[0];
|
||||||
|
changeAmount = parseInt(changeParts[1], 10) / 100000000;
|
||||||
|
}
|
||||||
|
} else if (part.startsWith('FEE:')) {
|
||||||
|
const feeData = part.substring(4);
|
||||||
|
onchainFeeAmount = parseInt(feeData, 10) / 100000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Error parsing OP_RETURN metadata for fees', { txid, error: error.message });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer blockHeight et blockTime si disponible
|
||||||
|
if (rawTx.confirmations > 0) {
|
||||||
|
try {
|
||||||
|
const txInfo = await this.client.getTransaction(txid);
|
||||||
|
blockHeight = txInfo.blockheight || null;
|
||||||
|
blockTime = txInfo.blocktime || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Error getting transaction block info for fees', { txid, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter seulement si frais trouvés
|
||||||
|
if (onchainFeeAmount !== null && onchainFeeAmount > 0) {
|
||||||
|
const feeSats = Math.round(onchainFeeAmount * 100000000);
|
||||||
|
const confirmations = rawTx.confirmations || 0;
|
||||||
|
newFees.push({
|
||||||
|
txid,
|
||||||
|
fee: onchainFeeAmount,
|
||||||
|
fee_sats: feeSats,
|
||||||
|
blockHeight: blockHeight || '',
|
||||||
|
blockTime: blockTime || '',
|
||||||
|
confirmations,
|
||||||
|
changeAddress: changeAddress || '',
|
||||||
|
changeAmount: changeAmount || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
if (processed % 10 === 0) {
|
||||||
|
logger.debug('Processing fees from anchors', { processed, total: totalAnchors });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Error processing anchor transaction for fees', { txid, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter les nouveaux frais au fichier
|
||||||
|
if (newFees.length > 0) {
|
||||||
|
const feeLines = newFees.map((fee) =>
|
||||||
|
`${fee.txid};${fee.fee};${fee.fee_sats};${fee.blockHeight};${fee.blockTime};${fee.confirmations};${fee.changeAddress};${fee.changeAmount}`
|
||||||
|
);
|
||||||
|
const existingLines = Array.from(existingFees.values());
|
||||||
|
const allLines = [...existingLines, ...feeLines];
|
||||||
|
writeFileSync(feesListPath, allLines.join('\n'), 'utf8');
|
||||||
|
logger.info('Fees list updated', { newFees: newFees.length, total: allLines.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
newFees: newFees.length,
|
||||||
|
totalFees: existingFees.size + newFees.length,
|
||||||
|
processed,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating fees from anchors', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtient le nombre d'ancrages en lisant directement depuis hash_list.txt
|
* Obtient le nombre d'ancrages en lisant directement depuis hash_list.txt
|
||||||
* Évite les appels RPC en utilisant le fichier texte comme source de vérité
|
* Évite les appels RPC en utilisant le fichier texte comme source de vérité
|
||||||
|
|||||||
@ -393,6 +393,26 @@ app.post('/api/utxo/consolidate', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route pour mettre à jour les frais depuis les transactions d'ancrage
|
||||||
|
app.post('/api/utxo/fees/update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sinceBlockHeight } = req.body;
|
||||||
|
const result = await bitcoinRPC.updateFeesFromAnchors(sinceBlockHeight || null);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
newFees: result.newFees,
|
||||||
|
totalFees: result.totalFees,
|
||||||
|
processed: result.processed,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating fees from anchors', { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Route pour servir le fichier texte des UTXO (utilisé pour chargement progressif)
|
// Route pour servir le fichier texte des UTXO (utilisé pour chargement progressif)
|
||||||
app.get('/api/utxo/list.txt', async (req, res) => {
|
app.get('/api/utxo/list.txt', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
13
update-proxy-nginx.sh
Executable file
13
update-proxy-nginx.sh
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run configure-nginx-proxy.sh on the proxy via SSH.
|
||||||
|
# Usage: ./update-proxy-nginx.sh
|
||||||
|
# Requires: SSH access to proxy (ncantu@192.168.1.100).
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROXY_HOST="192.168.1.100"
|
||||||
|
PROXY_USER="ncantu"
|
||||||
|
|
||||||
|
echo "=== Mise à jour Nginx sur le proxy (${PROXY_USER}@${PROXY_HOST}) ==="
|
||||||
|
ssh "${PROXY_USER}@${PROXY_HOST}" 'sudo bash -s' < "${SCRIPT_DIR}/configure-nginx-proxy.sh"
|
||||||
9
userwallet/.gitignore
vendored
Normal file
9
userwallet/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.development.local
|
||||||
44
userwallet/README.md
Normal file
44
userwallet/README.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# UserWallet Login
|
||||||
|
|
||||||
|
Site de login avec authentification secp256k1, conçu pour être utilisé en iframe par Channel Messages.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Authentification basée sur des clés secp256k1
|
||||||
|
- Communication avec le parent via `postMessage`
|
||||||
|
- Gestion de l'activation/désactivation du login par service
|
||||||
|
- Interface responsive et accessible
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le site sera accessible sur `http://localhost:3018`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Authentification** : Génération de paires de clés secp256k1, signature de challenges
|
||||||
|
- **Communication iframe** : Messages `postMessage` pour l'intégration
|
||||||
|
- **Stockage** : LocalStorage pour les clés et la configuration des services
|
||||||
|
- **Interface** : React + TypeScript avec accessibilité (ARIA)
|
||||||
|
|
||||||
|
## Types de messages iframe
|
||||||
|
|
||||||
|
- `auth-request` : Demande d'authentification depuis le parent
|
||||||
|
- `auth-response` : Réponse avec signature
|
||||||
|
- `service-toggle` : Activation/désactivation d'un service
|
||||||
|
- `service-status` : Envoi du statut des services
|
||||||
66
userwallet/docs/ports.md
Normal file
66
userwallet/docs/ports.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Configuration des ports - UserWallet
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## Ports utilisés
|
||||||
|
|
||||||
|
### Frontend (userwallet)
|
||||||
|
|
||||||
|
- **Port 3018** : Serveur de développement Vite
|
||||||
|
- Configuré dans `vite.config.ts`
|
||||||
|
- `strictPort: false` pour éviter les conflits
|
||||||
|
- Accessible sur `http://localhost:3018`
|
||||||
|
|
||||||
|
### API Relay (api-relay)
|
||||||
|
|
||||||
|
- **Port 3019** : Serveur Express.js du relais
|
||||||
|
- Configuré dans `api-relay/src/index.ts`
|
||||||
|
- Variable d'environnement `PORT` (défaut: 3019)
|
||||||
|
- Accessible sur `http://localhost:3019`
|
||||||
|
|
||||||
|
## Vérification des ports
|
||||||
|
|
||||||
|
Pour vérifier si un port est disponible :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
lsof -i :3018
|
||||||
|
lsof -i :3019
|
||||||
|
|
||||||
|
# Ou avec ss
|
||||||
|
ss -tuln | grep -E ":(3018|3019)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changement de ports
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Modifier `vite.config.ts` :
|
||||||
|
```typescript
|
||||||
|
server: {
|
||||||
|
port: <nouveau_port>,
|
||||||
|
strictPort: false,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Relay
|
||||||
|
|
||||||
|
Modifier via variable d'environnement :
|
||||||
|
```bash
|
||||||
|
PORT=3020 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou modifier `api-relay/src/index.ts` :
|
||||||
|
```typescript
|
||||||
|
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : <nouveau_port>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ports évités
|
||||||
|
|
||||||
|
Les ports suivants sont évités car potentiellement occupés :
|
||||||
|
- **3007** : Utilisé par d'autres services
|
||||||
|
- **8080** : Port commun, souvent occupé
|
||||||
|
- **3015** : Occupé (mempool1.4nkweb.com)
|
||||||
|
- **3016** : Réservé (git1.4nkweb.com)
|
||||||
|
- **3017** : Réservé (rocket1.4nkweb.com)
|
||||||
2046
userwallet/docs/specs.md
Normal file
2046
userwallet/docs/specs.md
Normal file
File diff suppressed because it is too large
Load Diff
171
userwallet/docs/storage.md
Normal file
171
userwallet/docs/storage.md
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
# Stockage des données - UserWallet
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
|
||||||
|
## Stockage sur le relais (api-relay)
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
Le relais utilise un stockage **hybride** : mémoire + persistance sur disque.
|
||||||
|
|
||||||
|
### Structure de stockage
|
||||||
|
|
||||||
|
**En mémoire :**
|
||||||
|
- `messages: Map<string, StoredMessage>` - Messages chiffrés indexés par hash
|
||||||
|
- `signatures: Map<string, StoredSignature[]>` - Signatures indexées par hash de message
|
||||||
|
- `keys: Map<string, StoredKey[]>` - Clés de déchiffrement indexées par hash de message
|
||||||
|
- `seenHashes: Set<string>` - Hash vus pour déduplication
|
||||||
|
|
||||||
|
**Sur disque :**
|
||||||
|
- Fichier `{STORAGE_PATH}/messages.json` contenant :
|
||||||
|
- `messages`: Array de `[hash, StoredMessage]`
|
||||||
|
- `seenHashes`: Array de hash (string[])
|
||||||
|
- `signatures`: Array de `[hash, StoredSignature[]]`
|
||||||
|
- `keys`: Array de `[hash, StoredKey[]]`
|
||||||
|
|
||||||
|
### Persistance
|
||||||
|
|
||||||
|
- **Chargement** : Au démarrage, charge `messages.json` si présent (ENOENT = premier run, démarrage à vide).
|
||||||
|
- **Sauvegarde** : À l’arrêt (SIGINT/SIGTERM) et périodiquement si `SAVE_INTERVAL_SECONDS` > 0 (défaut 300 s).
|
||||||
|
|
||||||
|
### Format de données
|
||||||
|
|
||||||
|
**StoredMessage :**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
msg: {
|
||||||
|
hash: string,
|
||||||
|
message_chiffre: string,
|
||||||
|
datajson_public: {
|
||||||
|
services_uuid: string[],
|
||||||
|
types_uuid: string[],
|
||||||
|
timestamp?: number,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
},
|
||||||
|
received_at: number,
|
||||||
|
relayed: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**StoredSignature :**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
msg: {
|
||||||
|
signature: {
|
||||||
|
hash: string,
|
||||||
|
cle_publique: string,
|
||||||
|
signature: string,
|
||||||
|
nonce: string,
|
||||||
|
materiel?: object
|
||||||
|
},
|
||||||
|
hash_cible?: string
|
||||||
|
},
|
||||||
|
received_at: number,
|
||||||
|
relayed: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**StoredKey :**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
msg: {
|
||||||
|
hash_message: string,
|
||||||
|
cle_de_chiffrement_message: {
|
||||||
|
algo: string,
|
||||||
|
params: object,
|
||||||
|
cle_chiffree?: string
|
||||||
|
},
|
||||||
|
df_ecdh_scannable: string
|
||||||
|
},
|
||||||
|
received_at: number,
|
||||||
|
relayed: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `STORAGE_PATH` : Chemin du répertoire de stockage (défaut: `./data`)
|
||||||
|
- `SAVE_INTERVAL_SECONDS` : Intervalle de sauvegarde périodique en secondes (défaut: 300). Mettre à 0 pour désactiver.
|
||||||
|
|
||||||
|
### Limitations actuelles
|
||||||
|
|
||||||
|
- Pas de base de données (SQLite/PostgreSQL recommandé en production)
|
||||||
|
- Pas de compression des données
|
||||||
|
|
||||||
|
## Stockage sur le front (userwallet)
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
Le front utilise **LocalStorage** du navigateur pour toutes les données locales.
|
||||||
|
|
||||||
|
### Structure de stockage
|
||||||
|
|
||||||
|
**Clés utilisées :**
|
||||||
|
|
||||||
|
1. **`userwallet_identity`** : Identité locale
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
uuid: string,
|
||||||
|
privateKey: string,
|
||||||
|
publicKey: string,
|
||||||
|
name?: string,
|
||||||
|
t0_anniversaire: number,
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`userwallet_relays`** : Configuration des relais
|
||||||
|
```typescript
|
||||||
|
Array<{
|
||||||
|
endpoint: string,
|
||||||
|
priority: number,
|
||||||
|
enabled: boolean,
|
||||||
|
last_sync?: number
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`userwallet_pairs`** : Configuration des pairs
|
||||||
|
```typescript
|
||||||
|
Array<{
|
||||||
|
uuid: string,
|
||||||
|
membres_parents_uuid: string[],
|
||||||
|
is_local: boolean,
|
||||||
|
can_sign: boolean
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **`userwallet_hash_cache`** : Cache des hash vus
|
||||||
|
```typescript
|
||||||
|
string[] // Array de hash
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **`userwallet_keypair`** : (Legacy) Paire de clés
|
||||||
|
6. **`userwallet_services`** : (Legacy) Services configurés
|
||||||
|
|
||||||
|
### Données en mémoire (non persistées)
|
||||||
|
|
||||||
|
- **Graphe contractuel** : Résolu dynamiquement depuis les messages synchronisés
|
||||||
|
- Services, Contrats, Champs, Actions, Membres, Pairs
|
||||||
|
- Stocké dans `GraphResolver.cache` (Map)
|
||||||
|
- Perdu au rechargement de page
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- **Taille limitée** : LocalStorage a une limite (~5-10MB selon navigateur)
|
||||||
|
- **Pas de synchronisation** : Données locales uniquement
|
||||||
|
- **Sécurité** : Clés privées stockées en clair (à chiffrer avec mot de passe)
|
||||||
|
- **Pas de backup** : Pas de mécanisme d'export/import automatique
|
||||||
|
|
||||||
|
### Recommandations
|
||||||
|
|
||||||
|
- Chiffrer les clés privées avec un mot de passe utilisateur
|
||||||
|
- Implémenter un mécanisme d'export/import
|
||||||
|
- Utiliser IndexedDB pour des données plus volumineuses
|
||||||
|
- Implémenter une synchronisation cloud optionnelle (chiffrée)
|
||||||
116
userwallet/docs/synthese.md
Normal file
116
userwallet/docs/synthese.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Synthèse structurée – UserWallet & API Relay
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## 1. userwallet/docs
|
||||||
|
|
||||||
|
### ports.md
|
||||||
|
|
||||||
|
- **UserWallet (Vite)** : port **3018** (`vite.config.ts`).
|
||||||
|
- **api-relay (Express)** : port **3019** (ou `PORT`), défaut 3019 dans `index.ts`. Le README api-relay indique 3019.
|
||||||
|
- **Ports à éviter** : 3007, 8080, 3015–3017.
|
||||||
|
|
||||||
|
### specs.md
|
||||||
|
|
||||||
|
Spécification du login décentralisé (secp256k1, contrats, pairing mFA, relais) :
|
||||||
|
|
||||||
|
- **Modèle** : messages publiés sans signatures ni clés ; signatures et clés publiées séparément ; tout adressé par hash canonique ; récupération par **GET uniquement** (pull).
|
||||||
|
- **Objets** : Service, Contrat, Champ, Action, ActionLogin, Membre, Pair, MessageBase, Hash, Signature, Validateurs, MsgChiffre, MsgSignature, MsgCle.
|
||||||
|
- **Graphe** : Service → Contrat → Champ → Action(login) → Membre → Pair ; contraintes « au moins 1 parent ».
|
||||||
|
- **Pairing** : UUID pair ↔ mots BIP32 ; pairing **obligatoire** pour login.
|
||||||
|
- **Écrans** : accueil, création/import identité, relais, pairing, sync, services, chemin login, challenge, signatures mFA, publication, résultat.
|
||||||
|
- **Machine à états** : S_INIT → S_HOME, S_SYNC_GLOBAL, S_PAIR_*, S_LOGIN_*, etc., avec erreurs récupérables / fatales.
|
||||||
|
|
||||||
|
### storage.md
|
||||||
|
|
||||||
|
- **Relais** : stockage hybride (mémoire + `./data/messages.json`). Messages + `seenHashes` persistés ; **signatures et clés non persistées** (perdues au redémarrage).
|
||||||
|
- **Front** : LocalStorage (`userwallet_identity`, `userwallet_relays`, `userwallet_pairs`, `userwallet_hash_cache`, plus legacy `userwallet_keypair`, `userwallet_services`). Graphe en mémoire uniquement (`GraphResolver`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. userwallet/ (frontend)
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
- React 18, React Router, Vite, TypeScript.
|
||||||
|
- Crypto : `@noble/secp256k1`, `@noble/hashes`.
|
||||||
|
- Pas de state global (hooks + services).
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
- **/components** : Home, CreateIdentity, ImportIdentity, Login, PairManagement, RelaySettings, Sync, ServiceList, ErrorDisplay, etc.
|
||||||
|
- **/hooks** : useIdentity, useChannel, useErrorHandler, useServices, etc.
|
||||||
|
- **/services** : GraphResolver, LoginBuilder, SyncService.
|
||||||
|
- **/utils** : relay, storage, identity, crypto, canonical, encryption, verification, cache, bip32, pairing, iframeChannel, etc.
|
||||||
|
- **/types** : identity, message, contract, auth, etc.
|
||||||
|
|
||||||
|
### Points notables
|
||||||
|
|
||||||
|
- **Identité** : création (secp256k1), import (dérivation clé publique depuis clé privée hex ; mnemonic/seed non supportés).
|
||||||
|
- **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.
|
||||||
|
- **Login** : LoginBuilder (challenge, nonce, chiffrement « for all », preuve avec signatures), publication message → signatures → clés.
|
||||||
|
- **Sync** : SyncService appelle les relais, HashCache (LocalStorage), déduplication, fetch clés/signatures, vérification hash/signatures/timestamp, mise à jour du graphe.
|
||||||
|
- **Iframe** : iframeChannel + useChannel ; messages `auth-request`, `auth-response`, `login-proof`, `service-status`, `error` ; postMessage vers parent avec `'*'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. api-relay (backend relais)
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
- Express, CORS, JSON. TypeScript, tsx en dev.
|
||||||
|
- Pas de base de données : mémoire + persistance optionnelle (`./data`).
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
- **/routes** : messages, signatures, keys, health.
|
||||||
|
- **/services** : StorageService, RelayService.
|
||||||
|
- **/types** : message (MsgChiffre, MsgSignature, MsgCle, Stored), config.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
- **GET /health** : `{ status: 'ok', timestamp }`.
|
||||||
|
- **GET /messages?start=&end=&service=** : messages dans la fenêtre, optionnellement par service.
|
||||||
|
- **POST /messages** : enregistrement + relais.
|
||||||
|
- **GET /messages/:hash** : message par hash.
|
||||||
|
- **GET/POST /signatures/:hash** et **/signatures** : idem pour signatures.
|
||||||
|
- **GET/POST /keys/:hash** et **/keys** : idem pour clés.
|
||||||
|
|
||||||
|
### Comportement
|
||||||
|
|
||||||
|
- **Storage** : messages, signatures, keys en mémoire ; `seenHashes` pour déduplication. Persistance : messages + `seenHashes` dans `messages.json` ; signatures et clés non sauvegardées.
|
||||||
|
- **Relay** : `PEER_RELAYS` en env ; relais des messages/signatures/clés vers les pairs via POST.
|
||||||
|
|
||||||
|
### Correctif relais (POST /messages)
|
||||||
|
|
||||||
|
- Vérifier `alreadySeen = storage.hasSeenHash(msg.hash)` **avant** `storeMessage`.
|
||||||
|
- Puis `storage.storeMessage(stored)`.
|
||||||
|
- Relayer seulement si `!alreadySeen` : `await relay.relayMessage(msg)` et `stored.relayed = true`.
|
||||||
|
|
||||||
|
### Autres points
|
||||||
|
|
||||||
|
- **config.ts** : RelayConfig avec `relay_enabled`, `max_message_age_days`, etc. Non utilisé dans `index.ts` (config réelle : PORT, HOST, STORAGE_PATH, PEER_RELAYS).
|
||||||
|
- **README** : port par défaut 3019.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Liens entre UserWallet et api-relay
|
||||||
|
|
||||||
|
- UserWallet appelle les endpoints du relais (messages, signatures, keys, health) via `utils/relay`.
|
||||||
|
- Types alignés : MsgChiffre, MsgSignature, MsgCle (structure équivalente, noms français).
|
||||||
|
- UserWallet utilise `datajson_public` (services_uuid, types_uuid, timestamp, etc.) comme le relais.
|
||||||
|
- Ports : front 3018, relais 3019 (voir `ports.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Synthèse
|
||||||
|
|
||||||
|
| Élément | userwallet | api-relay |
|
||||||
|
|-----------|--------------------------------------------------------------------|----------------------------------------------------------|
|
||||||
|
| **Rôle** | Front login décentralisé (secp256k1, contrats, pairing mFA) | Relais stockage + diffusion messages/signatures/clés |
|
||||||
|
| **Port** | 3018 | 3019 |
|
||||||
|
| **Stockage** | LocalStorage (identité, relais, pairs, hash cache) | Mémoire + JSON (messages, seenHashes) ; sig/keys non persistées |
|
||||||
|
| **Specs** | Aligné avec specs.md (graphe, ordre message→sig→clés, pull-only) | Séparation messages / signatures / clés, dédup par hash |
|
||||||
71
userwallet/eslint.config.mjs
Normal file
71
userwallet/eslint.config.mjs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import unusedImports from 'eslint-plugin-unused-imports';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist', 'node_modules'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
react,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'unused-imports': unusedImports,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'unused-imports/no-unused-imports': 'error',
|
||||||
|
'unused-imports/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
vars: 'all',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
args: 'after-used',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }],
|
||||||
|
'max-lines-per-function': ['error', { max: 40, skipBlankLines: true, skipComments: true }],
|
||||||
|
'max-params': ['error', 4],
|
||||||
|
'max-depth': ['error', 4],
|
||||||
|
complexity: ['error', 10],
|
||||||
|
'max-nested-callbacks': ['error', 3],
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||||
|
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'eqeqeq': ['error', 'always'],
|
||||||
|
'curly': ['error', 'all'],
|
||||||
|
'no-else-return': 'error',
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
236
userwallet/features/api-relay.md
Normal file
236
userwallet/features/api-relay.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# API Relay - Serveur de relais pour UserWallet
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-25
|
||||||
|
**Version:** 1.0
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Créer un serveur de relais séparé pour stocker et relayer les messages du système de login décentralisé UserWallet. Le relais respecte la séparation stricte entre messages, signatures et clés de déchiffrement.
|
||||||
|
|
||||||
|
## Impacts
|
||||||
|
|
||||||
|
### Fonctionnels
|
||||||
|
|
||||||
|
- Stockage des messages chiffrés (sans signatures ni clés)
|
||||||
|
- Stockage séparé des signatures
|
||||||
|
- Stockage séparé des clés de déchiffrement
|
||||||
|
- Relais entre pairs (inter-relay)
|
||||||
|
- Déduplication par hash pour éviter les doublons
|
||||||
|
- Endpoints REST pour GET/POST
|
||||||
|
|
||||||
|
### Techniques
|
||||||
|
|
||||||
|
- Serveur Express.js avec TypeScript
|
||||||
|
- Stockage en mémoire avec sauvegarde optionnelle sur disque
|
||||||
|
- Architecture modulaire avec services et routes séparés
|
||||||
|
- Support de la configuration via variables d'environnement
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- Déduplication par hash pour éviter les attaques de rejeu
|
||||||
|
- Séparation stricte des messages, signatures et clés
|
||||||
|
- Pas de fusion automatique lors des GET (respect de la spécification)
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
api-relay/
|
||||||
|
├── src/
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── messages.ts # Routes pour les messages chiffrés
|
||||||
|
│ │ ├── signatures.ts # Routes pour les signatures
|
||||||
|
│ │ ├── keys.ts # Routes pour les clés de déchiffrement
|
||||||
|
│ │ └── health.ts # Route de santé
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── storage.ts # Service de stockage
|
||||||
|
│ │ └── relay.ts # Service de relais entre pairs
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── message.ts # Types pour messages, signatures, clés
|
||||||
|
│ └── index.ts # Point d'entrée du serveur (config via env)
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── eslint.config.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fonctionnalités implémentées
|
||||||
|
|
||||||
|
1. **Stockage**
|
||||||
|
- Stockage en mémoire des messages, signatures et clés
|
||||||
|
- Sauvegarde optionnelle sur disque (JSON)
|
||||||
|
- Déduplication par hash
|
||||||
|
- Indexation par hash pour accès rapide
|
||||||
|
|
||||||
|
2. **Endpoints REST**
|
||||||
|
|
||||||
|
**Messages :**
|
||||||
|
- `GET /messages?start=<ts>&end=<ts>&service=<uuid>` - Récupérer les messages dans une fenêtre temporelle
|
||||||
|
- `POST /messages` - Publier un message chiffré
|
||||||
|
- `GET /messages/:hash` - Récupérer un message par hash
|
||||||
|
|
||||||
|
**Signatures :**
|
||||||
|
- `GET /signatures/:hash` - Récupérer les signatures pour un message
|
||||||
|
- `POST /signatures` - Publier une signature
|
||||||
|
|
||||||
|
**Clés :**
|
||||||
|
- `GET /keys/:hash` - Récupérer les clés de déchiffrement pour un message
|
||||||
|
- `POST /keys` - Publier une clé de déchiffrement
|
||||||
|
|
||||||
|
**Santé :**
|
||||||
|
- `GET /health` - Vérification de santé
|
||||||
|
|
||||||
|
3. **Relais entre pairs**
|
||||||
|
- Configuration de relais pairs via variable d'environnement
|
||||||
|
- Relais automatique des messages, signatures et clés
|
||||||
|
- Déduplication pour éviter les boucles de relais
|
||||||
|
|
||||||
|
4. **Filtrage**
|
||||||
|
- Filtrage par fenêtre temporelle (start/end)
|
||||||
|
- Filtrage optionnel par service UUID
|
||||||
|
- Tri par timestamp
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
#### StorageService
|
||||||
|
|
||||||
|
- Gestion du stockage en mémoire
|
||||||
|
- Déduplication par hash
|
||||||
|
- Méthodes pour messages, signatures et clés
|
||||||
|
- Sauvegarde/chargement depuis disque (optionnel)
|
||||||
|
|
||||||
|
#### RelayService
|
||||||
|
|
||||||
|
- Relais des messages vers les pairs configurés
|
||||||
|
- Gestion des erreurs de réseau
|
||||||
|
- Évite les boucles grâce à la déduplication
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm ou yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api-relay
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Variables d'environnement :
|
||||||
|
|
||||||
|
- `PORT` : Port d'écoute (défaut: 3019)
|
||||||
|
- `HOST` : Adresse d'écoute (défaut: 0.0.0.0)
|
||||||
|
- `STORAGE_PATH` : Chemin de stockage (défaut: ./data)
|
||||||
|
- `PEER_RELAYS` : Liste de relais pairs séparés par virgule
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3019 \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
STORAGE_PATH=./data \
|
||||||
|
PEER_RELAYS=http://relay1:3019,http://relay2:3019 \
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le serveur sera accessible sur `http://localhost:3019`
|
||||||
|
|
||||||
|
### Build de production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Déploiement
|
||||||
|
|
||||||
|
Le serveur peut être déployé sur n'importe quelle plateforme supportant Node.js :
|
||||||
|
- Serveur dédié
|
||||||
|
- Docker
|
||||||
|
- Cloud (AWS, GCP, Azure, etc.)
|
||||||
|
|
||||||
|
## Modalités d'analyse
|
||||||
|
|
||||||
|
### Vérification du fonctionnement
|
||||||
|
|
||||||
|
1. **Test de santé**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3019/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test de publication de message**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3019/messages \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"hash": "abc123",
|
||||||
|
"message_chiffre": "encrypted_data",
|
||||||
|
"datajson_public": {
|
||||||
|
"services_uuid": ["service-1"],
|
||||||
|
"types_uuid": ["type-1"],
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test de récupération de messages**
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3019/messages?start=0&end=9999999999"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test de publication de signature**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3019/signatures \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"signature": {
|
||||||
|
"hash": "abc123",
|
||||||
|
"cle_publique": "pubkey",
|
||||||
|
"signature": "sig",
|
||||||
|
"nonce": "nonce123"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Test de récupération de signatures**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3019/signatures/abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs et debugging
|
||||||
|
|
||||||
|
- Les erreurs sont loggées dans la console avec `console.error`
|
||||||
|
- Les opérations importantes sont loggées avec `console.log`
|
||||||
|
- Utiliser les DevTools ou un logger structuré en production
|
||||||
|
|
||||||
|
### Tests de performance
|
||||||
|
|
||||||
|
- Tester avec un volume important de messages
|
||||||
|
- Vérifier la déduplication fonctionne correctement
|
||||||
|
- Vérifier le relais entre pairs fonctionne
|
||||||
|
- Mesurer la latence des opérations
|
||||||
|
|
||||||
|
## Évolutions futures possibles
|
||||||
|
|
||||||
|
- **Base de données** : Remplacer le stockage en mémoire par une base de données (SQLite, PostgreSQL)
|
||||||
|
- **Authentification** : Ajouter une authentification pour les endpoints POST
|
||||||
|
- **Rate limiting** : Limiter le nombre de requêtes par IP
|
||||||
|
- **Compression** : Compresser les messages stockés
|
||||||
|
- **Indexation avancée** : Indexer par service UUID, type UUID pour des requêtes plus rapides
|
||||||
|
- **Bloom filter** : Implémenter un Bloom filter pour la déduplication à grande échelle
|
||||||
|
- **Merkle trees** : Support des arbres de Merkle pour la synchronisation efficace
|
||||||
|
- **WebSocket** : Support WebSocket pour les notifications en temps réel (optionnel, car le modèle est pull-only)
|
||||||
|
- **Métriques** : Exposer des métriques (Prometheus, etc.)
|
||||||
|
- **Logging structuré** : Utiliser un logger structuré (Winston, Pino, etc.)
|
||||||
370
userwallet/features/login-site-iframe.md
Normal file
370
userwallet/features/login-site-iframe.md
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
# Login Site with secp256k1 Authentication for Iframe Integration
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-25
|
||||||
|
**Version:** 2.0
|
||||||
|
|
||||||
|
## État d'avancement
|
||||||
|
|
||||||
|
### ✅ Implémenté
|
||||||
|
|
||||||
|
1. **Types TypeScript complets** selon les spécifications :
|
||||||
|
- `MessageBase`, `Hash`, `Signature`, `EncryptionKeys`, `DataJson`
|
||||||
|
- `MessageAValider`, `Validateurs`, `MembreRole`, `SignatureRequirement`
|
||||||
|
- `MsgChiffre`, `MsgSignature`, `MsgCle`
|
||||||
|
- `Service`, `Contrat`, `Champ`, `Action`, `ActionLogin`, `Membre`, `Pair`
|
||||||
|
- `LocalIdentity`, `RelayConfig`, `PairConfig`, `ServiceStatus`, `LoginPath`, `LoginChallenge`, `LoginProof`
|
||||||
|
|
||||||
|
2. **Utilitaires cryptographiques** :
|
||||||
|
- Génération de paires de clés secp256k1
|
||||||
|
- Signature et vérification de messages
|
||||||
|
- Calcul de hash canonique (sans hash, signatures, clés)
|
||||||
|
- Canonisation JSON
|
||||||
|
|
||||||
|
3. **Utilitaires BIP32** :
|
||||||
|
- Conversion UUID ↔ mots BIP32 pour le pairing
|
||||||
|
- Génération d'UUID
|
||||||
|
|
||||||
|
4. **Gestion des identités locales** :
|
||||||
|
- Création d'identité avec clés secp256k1
|
||||||
|
- Import d'identité (structure de base)
|
||||||
|
- Stockage LocalStorage
|
||||||
|
- Hook `useIdentity`
|
||||||
|
|
||||||
|
5. **Gestion des relais** :
|
||||||
|
- Configuration et stockage des relais
|
||||||
|
- Test de connectivité
|
||||||
|
- GET messages chiffrés, signatures, clés
|
||||||
|
- POST message, signatures, clés
|
||||||
|
|
||||||
|
6. **Gestion du pairing** :
|
||||||
|
- Création de pair local avec mots BIP32
|
||||||
|
- Ajout de pair distant depuis mots BIP32
|
||||||
|
- Vérification du statut de pairing
|
||||||
|
- Stockage LocalStorage
|
||||||
|
|
||||||
|
7. **Écran d'accueil** :
|
||||||
|
- Affichage du statut de l'identité
|
||||||
|
- Affichage du statut de pairing
|
||||||
|
- Affichage du statut réseau
|
||||||
|
- Navigation vers les autres écrans
|
||||||
|
|
||||||
|
8. **Structure de navigation** :
|
||||||
|
- React Router configuré
|
||||||
|
- Routes de base pour tous les écrans
|
||||||
|
|
||||||
|
### ✅ Implémenté (suite)
|
||||||
|
|
||||||
|
9. **Résolution du graphe contractuel** :
|
||||||
|
- Service `GraphResolver` pour résoudre Service → Contrat → Champ → Action → Membre → Pair
|
||||||
|
- Vérification des contraintes "au moins 1 parent"
|
||||||
|
- Calcul des validateurs et signatures requises
|
||||||
|
- Cache local du graphe
|
||||||
|
|
||||||
|
10. **Construction et publication des messages de login** :
|
||||||
|
- Service `LoginBuilder` pour construire les challenges
|
||||||
|
- Génération du nonce
|
||||||
|
- Construction MessageBase avec datajson
|
||||||
|
- Chiffrement du message (base64 pour l'instant)
|
||||||
|
- Calcul du hash canonique
|
||||||
|
- Publication en ordre strict : message → signatures → clés
|
||||||
|
|
||||||
|
11. **Synchronisation** :
|
||||||
|
- Service `SyncService` pour synchroniser depuis les relais
|
||||||
|
- Déduplication par hash
|
||||||
|
- Fetch séparé des signatures et clés
|
||||||
|
- Mise à jour du graphe local
|
||||||
|
- Compteurs de statistiques
|
||||||
|
|
||||||
|
12. **Écrans complets** :
|
||||||
|
- Écran d'accueil avec statuts
|
||||||
|
- Création d'identité locale
|
||||||
|
- Import d'identité
|
||||||
|
- Gestion des relais (liste, ajout, test, activation/désactivation)
|
||||||
|
- Gestion des pairs (liste, ajout local avec mots BIP32, ajout distant)
|
||||||
|
- Synchronisation globale
|
||||||
|
- Écran de login (construction du chemin, challenge, publication)
|
||||||
|
|
||||||
|
### ✅ Améliorations récentes
|
||||||
|
|
||||||
|
13. **Chiffrement réel** :
|
||||||
|
- Remplacement du base64 par AES-GCM
|
||||||
|
- Support ECDH pour le partage de clés
|
||||||
|
- Fonctions `encryptWithECDH` / `decryptWithECDH` pour chiffrement point-à-point
|
||||||
|
- Fonctions `encryptForAll` / `decryptForAll` pour publication "pour tous"
|
||||||
|
|
||||||
|
14. **Vérification locale avancée** :
|
||||||
|
- Service `verification.ts` avec vérification du hash canonique
|
||||||
|
- Vérification des signatures secp256k1
|
||||||
|
- Cache de nonces pour anti-rejeu (`NonceCache`)
|
||||||
|
- Vérification des timestamps dans une fenêtre acceptable
|
||||||
|
- Intégration dans `SyncService` pour validation automatique
|
||||||
|
|
||||||
|
15. **Gestion des erreurs** :
|
||||||
|
- Hook `useErrorHandler` pour gestion centralisée
|
||||||
|
- Composant `ErrorDisplay` pour affichage utilisateur
|
||||||
|
- Messages d'erreur contextuels avec codes
|
||||||
|
- Détails techniques disponibles en mode développeur
|
||||||
|
- Intégration dans tous les écrans principaux
|
||||||
|
|
||||||
|
### ✅ Nouvelles fonctionnalités
|
||||||
|
|
||||||
|
16. **Communication iframe Channel Messages** :
|
||||||
|
- Utilitaires `iframeChannel.ts` pour communication bidirectionnelle
|
||||||
|
- Hook `useChannel` pour écouter et envoyer des messages
|
||||||
|
- Support des messages : `auth-request`, `auth-response`, `login-proof`, `service-status`, `error`
|
||||||
|
- Intégration automatique dans l'application principale
|
||||||
|
- Envoi automatique de la preuve de login au parent
|
||||||
|
|
||||||
|
17. **Cache persistant des hash** :
|
||||||
|
- Classe `HashCache` pour éviter le rescan des messages déjà vus
|
||||||
|
- Persistance dans LocalStorage
|
||||||
|
- Pruning automatique si le cache devient trop volumineux (>10000 entrées)
|
||||||
|
- Intégration dans `SyncService` pour optimisation
|
||||||
|
|
||||||
|
18. **Écran de liste des services** :
|
||||||
|
- Affichage des services découverts via synchronisation
|
||||||
|
- Statut de contrat (complet/incomplet, valide/invalide)
|
||||||
|
- Sélection directe d'un service pour login
|
||||||
|
- Navigation depuis l'écran d'accueil
|
||||||
|
|
||||||
|
19. **Améliorations UX** :
|
||||||
|
- Support des paramètres URL pour pré-remplir le service dans le login
|
||||||
|
- Meilleure intégration entre écrans
|
||||||
|
- Feedback visuel amélioré
|
||||||
|
|
||||||
|
### 🚧 À améliorer/optimiser
|
||||||
|
|
||||||
|
1. **Optimisations avancées** :
|
||||||
|
- Bloom filter pour éviter le rescan à grande échelle
|
||||||
|
- Arbres de Merkle (optionnel) pour synchronisation efficace
|
||||||
|
- Compression des données en cache
|
||||||
|
|
||||||
|
2. **Fonctionnalités iframe** :
|
||||||
|
- Support de l'activation/désactivation de services depuis l'iframe
|
||||||
|
- Gestion des événements de login depuis le parent
|
||||||
|
|
||||||
|
3. **Gestion des erreurs avancée** :
|
||||||
|
- Retry automatique pour les publications
|
||||||
|
- Gestion des timeouts réseau avec backoff exponentiel
|
||||||
|
- Queue de publication pour gérer les échecs
|
||||||
|
|
||||||
|
4. **UI/UX supplémentaires** :
|
||||||
|
- Indicateurs de progression pour les opérations longues
|
||||||
|
- Animations de transition
|
||||||
|
- Mode sombre/clair
|
||||||
|
- Internationalisation (i18n)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Créer un site de login avec authentification basée sur des clés secp256k1, conçu pour être intégré en iframe par Channel Messages. Le site permet d'activer ou désactiver le login sur des services depuis une identité numérique à base de clé secp256k1.
|
||||||
|
|
||||||
|
## Impacts
|
||||||
|
|
||||||
|
### Fonctionnels
|
||||||
|
|
||||||
|
- Authentification sécurisée via signature de challenges avec clés secp256k1
|
||||||
|
- Communication bidirectionnelle avec le parent via `postMessage`
|
||||||
|
- Gestion de l'activation/désactivation du login par service
|
||||||
|
- Génération et stockage sécurisé des paires de clés
|
||||||
|
- Interface utilisateur accessible et responsive
|
||||||
|
|
||||||
|
### Techniques
|
||||||
|
|
||||||
|
- Nouveau projet React + TypeScript avec Vite
|
||||||
|
- Utilisation de `@noble/secp256k1` pour la cryptographie
|
||||||
|
- Stockage local des clés et configuration via LocalStorage
|
||||||
|
- Architecture modulaire avec séparation des responsabilités
|
||||||
|
- Support iframe avec détection automatique du contexte
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- Clés privées stockées localement (LocalStorage)
|
||||||
|
- Signature de challenges pour l'authentification
|
||||||
|
- Communication iframe sécurisée via `postMessage`
|
||||||
|
- Pas de transmission de clés privées
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
userwallet/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── LoginForm.tsx # Formulaire de connexion
|
||||||
|
│ │ └── ServiceList.tsx # Liste des services avec toggles
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useAuth.ts # Hook d'authentification
|
||||||
|
│ │ └── useServices.ts # Hook de gestion des services
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── auth.ts # Types TypeScript pour l'auth
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── crypto.ts # Fonctions cryptographiques secp256k1
|
||||||
|
│ │ ├── iframe.ts # Communication iframe
|
||||||
|
│ │ └── storage.ts # Gestion LocalStorage
|
||||||
|
│ ├── App.tsx # Composant principal
|
||||||
|
│ ├── main.tsx # Point d'entrée
|
||||||
|
│ └── index.css # Styles globaux
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── vite.config.ts
|
||||||
|
└── eslint.config.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fonctionnalités implémentées
|
||||||
|
|
||||||
|
1. **Authentification secp256k1**
|
||||||
|
- Génération de paires de clés au premier chargement
|
||||||
|
- Signature de challenges pour l'authentification
|
||||||
|
- Vérification de signatures (pour usage futur)
|
||||||
|
|
||||||
|
2. **Communication iframe**
|
||||||
|
- Détection automatique du contexte iframe
|
||||||
|
- Messages `postMessage` vers le parent
|
||||||
|
- Support des messages : `auth-request`, `auth-response`, `service-toggle`, `service-status`
|
||||||
|
|
||||||
|
3. **Gestion des services**
|
||||||
|
- Stockage de la configuration des services
|
||||||
|
- Activation/désactivation par service
|
||||||
|
- Synchronisation avec le parent via messages
|
||||||
|
|
||||||
|
4. **Interface utilisateur**
|
||||||
|
- Formulaire de connexion
|
||||||
|
- Liste des services avec toggles
|
||||||
|
- Affichage de la clé publique (tronquée)
|
||||||
|
- Design responsive avec support dark mode
|
||||||
|
|
||||||
|
### Types de messages iframe
|
||||||
|
|
||||||
|
#### `auth-request`
|
||||||
|
Demande d'authentification depuis le parent.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'auth-request',
|
||||||
|
payload: {
|
||||||
|
serviceId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `auth-response`
|
||||||
|
Réponse avec signature.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'auth-response',
|
||||||
|
payload: {
|
||||||
|
signature: string,
|
||||||
|
publicKey: string,
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `service-toggle`
|
||||||
|
Activation/désactivation d'un service.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'service-toggle',
|
||||||
|
payload: {
|
||||||
|
serviceId: string,
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `service-status`
|
||||||
|
Envoi du statut des services.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'service-status',
|
||||||
|
payload: {
|
||||||
|
services: ServiceConfig[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm ou yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le site sera accessible sur `http://localhost:3007`
|
||||||
|
|
||||||
|
### Build de production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers de production seront générés dans `dist/`
|
||||||
|
|
||||||
|
### Déploiement
|
||||||
|
|
||||||
|
Le site doit être déployé sur un serveur web accessible via HTTPS pour être utilisé en iframe. Le port configuré est 3007 (configurable dans `vite.config.ts`).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **Port** : Modifiable dans `vite.config.ts` (défaut: 3007)
|
||||||
|
- **Stockage** : LocalStorage (clés: `userwallet_keypair`, `userwallet_services`)
|
||||||
|
- **Origine iframe** : Le site accepte les messages de n'importe quelle origine (`*`). Pour la production, restreindre à l'origine du parent.
|
||||||
|
|
||||||
|
## Modalités d'analyse
|
||||||
|
|
||||||
|
### Vérification du fonctionnement
|
||||||
|
|
||||||
|
1. **Test en standalone**
|
||||||
|
- Ouvrir `http://localhost:3007`
|
||||||
|
- Vérifier la génération automatique d'une paire de clés
|
||||||
|
- Tester le formulaire de connexion
|
||||||
|
- Vérifier l'affichage de la clé publique
|
||||||
|
|
||||||
|
2. **Test en iframe**
|
||||||
|
- Intégrer dans une page parent avec `<iframe src="http://localhost:3007"></iframe>`
|
||||||
|
- Envoyer un message `auth-request` depuis le parent
|
||||||
|
- Vérifier la réception du message `auth-response`
|
||||||
|
- Tester l'activation/désactivation de services
|
||||||
|
|
||||||
|
3. **Vérification des messages**
|
||||||
|
- Ouvrir la console du navigateur
|
||||||
|
- Vérifier l'absence d'erreurs
|
||||||
|
- Vérifier l'envoi des messages `postMessage`
|
||||||
|
|
||||||
|
### Logs et debugging
|
||||||
|
|
||||||
|
- Les erreurs sont loggées dans la console avec `console.error`
|
||||||
|
- Les avertissements (ex: pas en iframe) utilisent `console.warn`
|
||||||
|
- Utiliser les DevTools pour inspecter les messages `postMessage`
|
||||||
|
|
||||||
|
### Tests de sécurité
|
||||||
|
|
||||||
|
- Vérifier que les clés privées ne sont jamais transmises
|
||||||
|
- Vérifier que les signatures sont valides
|
||||||
|
- Tester la vérification de signatures avec `verifySignature()`
|
||||||
|
|
||||||
|
## Évolutions futures possibles
|
||||||
|
|
||||||
|
- Restriction de l'origine du parent pour la sécurité
|
||||||
|
- Support de plusieurs identités/utilisateurs
|
||||||
|
- Export/import de clés
|
||||||
|
- Chiffrement des clés privées avec mot de passe
|
||||||
|
- Intégration avec un backend pour la validation
|
||||||
|
- Support de protocoles d'authentification supplémentaires
|
||||||
34
userwallet/features/nginx-proxy-userwallet.md
Normal file
34
userwallet/features/nginx-proxy-userwallet.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Configuration Nginx proxy pour userwallet.certificator.4nkweb.com
|
||||||
|
|
||||||
|
**Author:** Équipe 4NK
|
||||||
|
**Date:** 2026-01-26
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Exposer le frontend UserWallet (Vite, port 3018) sur **userwallet.certificator.4nkweb.com** via le proxy Nginx sur 192.168.1.100, en faisant pointer le trafic vers l’hôte bitcoin (192.168.1.105).
|
||||||
|
|
||||||
|
## Impacts
|
||||||
|
|
||||||
|
- **Fonctionnels** : Accès à UserWallet via `https://userwallet.certificator.4nkweb.com` (après Certbot).
|
||||||
|
- **Techniques** : Nouveau vhost Nginx sur le proxy ; proxy_pass vers `http://192.168.1.105:3018`.
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
- **Script** : `configure-nginx-proxy.sh` (racine du dépôt bitcoin)
|
||||||
|
- Ajout d’un bloc serveur `userwallet.certificator.4nkweb.com` (listen 80, proxy vers 192.168.1.105:3018).
|
||||||
|
- Fichier de config : `NGINX_SITES_AVAILABLE/userwallet.certificator.4nkweb.com`.
|
||||||
|
- Symlink dans `sites-enabled`.
|
||||||
|
- Ajout de `userwallet.certificator.4nkweb.com` dans la liste des domaines Certbot pour HTTPS et redirection.
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
- **Service web sur .105** : Certbot a besoin d’un service qui répond sur **192.168.1.105:3018**. Sur l’hôte bitcoin (.105) :
|
||||||
|
- Lancer `./userwallet/start.sh` (build + `vite preview` sur 3018), **ou**
|
||||||
|
- Installer l’unité systemd `userwallet/userwallet.service` et `systemctl enable --now userwallet`.
|
||||||
|
- Exécuter `configure-nginx-proxy.sh` (ou `update-proxy-nginx.sh`) sur le **proxy** (192.168.1.100).
|
||||||
|
- Certbot configure HTTPS et la redirection HTTP → HTTPS pour `userwallet.certificator.4nkweb.com`.
|
||||||
|
|
||||||
|
## Modalités d’analyse
|
||||||
|
|
||||||
|
- Depuis l’extérieur : `curl -I https://userwallet.certificator.4nkweb.com` → 200 (ou 304).
|
||||||
|
- Vérifier les logs Nginx : `access_log` et `error_log` du vhost `userwallet.certificator.4nkweb.com`.
|
||||||
13
userwallet/index.html
Normal file
13
userwallet/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>UserWallet Login</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5538
userwallet/package-lock.json
generated
Normal file
5538
userwallet/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
userwallet/package.json
Normal file
35
userwallet/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "userwallet-login",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Login site with secp256k1 authentication for iframe integration",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
userwallet/src/App.tsx
Normal file
36
userwallet/src/App.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { HomeScreen } from './components/HomeScreen';
|
||||||
|
import { CreateIdentityScreen } from './components/CreateIdentityScreen';
|
||||||
|
import { ImportIdentityScreen } from './components/ImportIdentityScreen';
|
||||||
|
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 { useChannel } from './hooks/useChannel';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
function AppContent(): JSX.Element {
|
||||||
|
useChannel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomeScreen />} />
|
||||||
|
<Route path="/create-identity" element={<CreateIdentityScreen />} />
|
||||||
|
<Route path="/import-identity" element={<ImportIdentityScreen />} />
|
||||||
|
<Route path="/login" element={<LoginScreen />} />
|
||||||
|
<Route path="/manage-pairs" element={<PairManagementScreen />} />
|
||||||
|
<Route path="/relay-settings" element={<RelaySettingsScreen />} />
|
||||||
|
<Route path="/sync" element={<SyncScreen />} />
|
||||||
|
<Route path="/services" element={<ServiceListScreen />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppContent />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
userwallet/src/components/CreateIdentityScreen.tsx
Normal file
65
userwallet/src/components/CreateIdentityScreen.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
|
import { useErrorHandler } from '../hooks/useErrorHandler';
|
||||||
|
import { ErrorDisplay } from './ErrorDisplay';
|
||||||
|
|
||||||
|
export function CreateIdentityScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { createNewIdentity } = useIdentity();
|
||||||
|
const { error, handleError, clearError } = useErrorHandler();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsCreating(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
createNewIdentity(name || undefined);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors de la création de l\'identité');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Créer une identité locale</h1>
|
||||||
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
||||||
|
<form onSubmit={handleSubmit} aria-label="Formulaire de création d'identité">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name">
|
||||||
|
Nom local de l'identité (optionnel)
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Mon identité"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={isCreating}>
|
||||||
|
{isCreating ? 'Création...' : "Générer l'identité"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate('/')}>
|
||||||
|
Annuler
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
userwallet/src/components/ErrorDisplay.tsx
Normal file
57
userwallet/src/components/ErrorDisplay.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import type { AppError } from '../hooks/useErrorHandler';
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
error: AppError;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorDisplay({ error, onDismiss }: ErrorDisplayProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
backgroundColor: '#fee',
|
||||||
|
border: '1px solid #fcc',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<strong>Erreur</strong>
|
||||||
|
{error.code !== undefined && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
|
({error.code})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p>{error.message}</p>
|
||||||
|
{error.details !== undefined && (
|
||||||
|
<details style={{ marginTop: '0.5rem' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
|
Détails techniques
|
||||||
|
</summary>
|
||||||
|
<pre style={{ fontSize: '0.75rem', marginTop: '0.5rem', overflow: 'auto' }}>
|
||||||
|
{JSON.stringify(error.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Fermer l'erreur"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
userwallet/src/components/HomeScreen.tsx
Normal file
97
userwallet/src/components/HomeScreen.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
|
import { isPairingSatisfied } from '../utils/pairing';
|
||||||
|
import { getStoredRelays } from '../utils/relay';
|
||||||
|
|
||||||
|
export function HomeScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { identity, isLoading } = useIdentity();
|
||||||
|
const pairingSatisfied = isPairingSatisfied();
|
||||||
|
const relays = getStoredRelays();
|
||||||
|
const relayStatus = relays.length > 0 ? 'OK' : 'Non configuré';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite" aria-busy="true">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identity === null) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>UserWallet Login</h1>
|
||||||
|
<p>Identité locale absente</p>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/create-identity')}>
|
||||||
|
Créer une identité locale
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/import-identity')}>
|
||||||
|
Importer une identité
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
userwallet/src/components/ImportIdentityScreen.tsx
Normal file
81
userwallet/src/components/ImportIdentityScreen.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
|
|
||||||
|
export function ImportIdentityScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { importExistingIdentity } = useIdentity();
|
||||||
|
const [seedOrKey, setSeedOrKey] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const imported = importExistingIdentity(seedOrKey, name || undefined);
|
||||||
|
if (imported === null) {
|
||||||
|
setError('Import échoué. Vérifiez le format du seed ou de la clé privée.');
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Erreur lors de l'import");
|
||||||
|
console.error('Error importing identity:', err);
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Importer une identité</h1>
|
||||||
|
<form onSubmit={handleSubmit} aria-label="Formulaire d'import d'identité">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="seed">
|
||||||
|
Seed / Phrase / Clé privée
|
||||||
|
<textarea
|
||||||
|
id="seed"
|
||||||
|
value={seedOrKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSeedOrKey(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Entrez votre seed, phrase ou clé privée"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name">
|
||||||
|
Nom local (optionnel)
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Mon identité"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error !== null && (
|
||||||
|
<div role="alert" aria-live="assertive">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={isImporting}>
|
||||||
|
{isImporting ? 'Import...' : 'Importer'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate('/')}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
userwallet/src/components/LoginForm.tsx
Normal file
49
userwallet/src/components/LoginForm.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { sendAuthResponse } from '../utils/iframe';
|
||||||
|
|
||||||
|
export function LoginForm(): JSX.Element {
|
||||||
|
const { keyPair, isLoading, createAuthResponse } = useAuth();
|
||||||
|
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (keyPair === null || isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
const response = createAuthResponse();
|
||||||
|
if (response !== null) {
|
||||||
|
sendAuthResponse(response);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to create auth response');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite" aria-busy="true">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} aria-label="Formulaire de connexion">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isAuthenticating || keyPair === null}
|
||||||
|
aria-busy={isAuthenticating}
|
||||||
|
>
|
||||||
|
{isAuthenticating ? 'Authentification...' : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
userwallet/src/components/LoginScreen.tsx
Normal file
299
userwallet/src/components/LoginScreen.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
|
import { useErrorHandler } from '../hooks/useErrorHandler';
|
||||||
|
import { ErrorDisplay } from './ErrorDisplay';
|
||||||
|
import { getStoredRelays } from '../utils/relay';
|
||||||
|
import { GraphResolver } from '../services/graphResolver';
|
||||||
|
import { LoginBuilder } from '../services/loginBuilder';
|
||||||
|
import { postMessageChiffre, postSignature } from '../utils/relay';
|
||||||
|
import { useChannel } from '../hooks/useChannel';
|
||||||
|
import type { LoginPath, LoginProof } from '../types/identity';
|
||||||
|
import type { MsgSignature } from '../types/message';
|
||||||
|
|
||||||
|
export function LoginScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
const { error, handleError, clearError } = useErrorHandler();
|
||||||
|
const { sendLoginProof } = useChannel();
|
||||||
|
const [serviceUuid, setServiceUuid] = useState(searchParams.get('service') ?? '');
|
||||||
|
const [membreUuid, setMembreUuid] = useState('');
|
||||||
|
const [loginPath, setLoginPath] = useState<LoginPath | null>(null);
|
||||||
|
const [proof, setProof] = useState<LoginProof | null>(null);
|
||||||
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
|
|
||||||
|
const graphResolver = new GraphResolver();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serviceParam = searchParams.get('service');
|
||||||
|
if (serviceParam !== null) {
|
||||||
|
setServiceUuid(serviceParam);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleBuildPath = async (): Promise<void> => {
|
||||||
|
if (serviceUuid === '' || membreUuid === '') {
|
||||||
|
handleError('Service UUID et Membre UUID requis', 'MISSING_PARAMS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBuilding(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const path = graphResolver.resolveLoginPath(serviceUuid, membreUuid);
|
||||||
|
if (path === null) {
|
||||||
|
handleError('Impossible de résoudre le chemin de login', 'PATH_RESOLUTION_FAILED');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoginPath(path);
|
||||||
|
if (path.statut === 'incomplet') {
|
||||||
|
handleError('Chemin incomplet. Synchronisez d\'abord les données.', 'INCOMPLETE_PATH');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors de la construction du chemin');
|
||||||
|
} finally {
|
||||||
|
setIsBuilding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuildChallenge = async (): Promise<void> => {
|
||||||
|
if (identity === null || loginPath === null) {
|
||||||
|
handleError('Identité ou chemin de login manquant', 'MISSING_REQUIREMENTS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginPath.statut !== 'complet') {
|
||||||
|
handleError('Le chemin de login doit être complet avant de construire le challenge', 'INCOMPLETE_PATH');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBuilding(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const relays = getStoredRelays().map((r) => r.endpoint);
|
||||||
|
if (relays.length === 0) {
|
||||||
|
handleError('Aucun relais configuré', 'NO_RELAYS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginBuilder = new LoginBuilder(identity, relays);
|
||||||
|
|
||||||
|
const challenge = await loginBuilder.buildChallenge(
|
||||||
|
loginPath.service_uuid,
|
||||||
|
loginPath.action_login_uuid,
|
||||||
|
loginPath.action_login_uuid,
|
||||||
|
loginPath.membre_uuid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatures: Array<{
|
||||||
|
signature: string;
|
||||||
|
cle_publique: string;
|
||||||
|
nonce: string;
|
||||||
|
pair_uuid: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const req of loginPath.signatures_requises) {
|
||||||
|
if (req.pair_uuid === undefined) {
|
||||||
|
handleError('Pair UUID manquant pour une signature requise', 'MISSING_PAIR_UUID');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sig = loginBuilder.signChallenge(
|
||||||
|
challenge,
|
||||||
|
req.pair_uuid,
|
||||||
|
identity.privateKey,
|
||||||
|
);
|
||||||
|
signatures.push({
|
||||||
|
signature: sig,
|
||||||
|
cle_publique: identity.publicKey,
|
||||||
|
nonce: challenge.nonce,
|
||||||
|
pair_uuid: req.pair_uuid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatures.length === 0) {
|
||||||
|
handleError('Aucune signature générée', 'NO_SIGNATURES');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProof = await loginBuilder.buildProof(challenge, signatures);
|
||||||
|
setProof(newProof);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors de la construction du challenge');
|
||||||
|
} finally {
|
||||||
|
setIsBuilding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async (): Promise<void> => {
|
||||||
|
if (identity === null || proof === null) {
|
||||||
|
handleError('Identité ou preuve manquante', 'MISSING_REQUIREMENTS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPublishing(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const relays = getStoredRelays().filter((r) => r.enabled);
|
||||||
|
if (relays.length === 0) {
|
||||||
|
handleError('Aucun relais activé', 'NO_ENABLED_RELAYS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginBuilder = new LoginBuilder(identity, relays.map((r) => r.endpoint));
|
||||||
|
const msgChiffre = loginBuilder.challengeToMsgChiffre(proof.challenge);
|
||||||
|
|
||||||
|
const publishResults: Array<{ relay: string; success: boolean }> = [];
|
||||||
|
|
||||||
|
for (const relay of relays) {
|
||||||
|
try {
|
||||||
|
await postMessageChiffre(relay.endpoint, msgChiffre);
|
||||||
|
|
||||||
|
for (const sig of proof.signatures) {
|
||||||
|
const msgSig: MsgSignature = {
|
||||||
|
signature: {
|
||||||
|
hash: proof.challenge.hash,
|
||||||
|
cle_publique: sig.cle_publique,
|
||||||
|
signature: sig.signature,
|
||||||
|
nonce: sig.nonce,
|
||||||
|
},
|
||||||
|
hash_cible: proof.challenge.hash,
|
||||||
|
};
|
||||||
|
await postSignature(relay.endpoint, msgSig);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishResults.push({ relay: relay.endpoint, success: true });
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, `Erreur lors de la publication sur ${relay.endpoint}`);
|
||||||
|
publishResults.push({ relay: relay.endpoint, success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = publishResults.filter((r) => r.success).length;
|
||||||
|
if (successCount === 0) {
|
||||||
|
handleError('Échec de la publication sur tous les relais', 'PUBLISH_FAILED');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount < relays.length) {
|
||||||
|
handleError(
|
||||||
|
`Publication partielle: ${successCount}/${relays.length} relais`,
|
||||||
|
'PARTIAL_PUBLISH',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProof = { ...proof, statut: 'publie' as const };
|
||||||
|
setProof(updatedProof);
|
||||||
|
sendLoginProof(updatedProof);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors de la publication');
|
||||||
|
} finally {
|
||||||
|
setIsPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (identity === null) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<p>Identité requise pour se connecter</p>
|
||||||
|
<button onClick={() => navigate('/')}>Retour</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Se connecter</h1>
|
||||||
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
||||||
|
<section aria-labelledby="service-selection">
|
||||||
|
<h2 id="service-selection">Sélection du service</h2>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="service-uuid">
|
||||||
|
Service UUID
|
||||||
|
<input
|
||||||
|
id="service-uuid"
|
||||||
|
type="text"
|
||||||
|
value={serviceUuid}
|
||||||
|
onChange={(e) => {
|
||||||
|
setServiceUuid(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="service-uuid"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="membre-uuid">
|
||||||
|
Membre UUID
|
||||||
|
<input
|
||||||
|
id="membre-uuid"
|
||||||
|
type="text"
|
||||||
|
value={membreUuid}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMembreUuid(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="membre-uuid"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleBuildPath} disabled={isBuilding}>
|
||||||
|
{isBuilding ? 'Construction...' : 'Construire le chemin'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
{loginPath !== null && (
|
||||||
|
<section aria-labelledby="login-path">
|
||||||
|
<h2 id="login-path">Chemin de login</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Statut:</strong> {loginPath.statut}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Service:</strong> {loginPath.service_uuid}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Action login:</strong> {loginPath.action_login_uuid}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Membre:</strong> {loginPath.membre_uuid}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Pairs attendus:</strong> {loginPath.pairs_attendus.length}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Signatures requises:</strong> {loginPath.signatures_requises.length}
|
||||||
|
</p>
|
||||||
|
{loginPath.statut === 'complet' && (
|
||||||
|
<button onClick={handleBuildChallenge} disabled={isBuilding}>
|
||||||
|
{isBuilding ? 'Construction...' : 'Construire le challenge'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{proof !== null && (
|
||||||
|
<section aria-labelledby="login-proof">
|
||||||
|
<h2 id="login-proof">Preuve de login</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Statut:</strong> {proof.statut}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Hash:</strong> {proof.challenge.hash.slice(0, 16)}...
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Signatures:</strong> {proof.signatures.length}
|
||||||
|
</p>
|
||||||
|
{proof.statut === 'en_attente' && (
|
||||||
|
<button onClick={handlePublish} disabled={isPublishing}>
|
||||||
|
{isPublishing ? 'Publication...' : 'Publier la preuve'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/')}>Retour</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
userwallet/src/components/PairManagementScreen.tsx
Normal file
117
userwallet/src/components/PairManagementScreen.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getStoredPairs,
|
||||||
|
createLocalPair,
|
||||||
|
addRemotePairFromWords,
|
||||||
|
} from '../utils/pairing';
|
||||||
|
import type { PairConfig } from '../types/identity';
|
||||||
|
|
||||||
|
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('');
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pair = addRemotePairFromWords(wordList, []);
|
||||||
|
if (pair === null) {
|
||||||
|
alert('Mots invalides. Vérifiez la saisie.');
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
userwallet/src/components/RelaySettingsScreen.tsx
Normal file
121
userwallet/src/components/RelaySettingsScreen.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getStoredRelays, storeRelays, testRelay } from '../utils/relay';
|
||||||
|
import type { RelayConfig } from '../types/identity';
|
||||||
|
|
||||||
|
export function RelaySettingsScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [relays, setRelays] = useState<RelayConfig[]>([]);
|
||||||
|
const [newEndpoint, setNewEndpoint] = useState('');
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRelays(getStoredRelays());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = (): void => {
|
||||||
|
if (newEndpoint.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newRelay: RelayConfig = {
|
||||||
|
endpoint: newEndpoint.trim(),
|
||||||
|
priority: relays.length,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
const updated = [...relays, newRelay];
|
||||||
|
setRelays(updated);
|
||||||
|
storeRelays(updated);
|
||||||
|
setNewEndpoint('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (index: number): void => {
|
||||||
|
const updated = relays.filter((_, i) => i !== index);
|
||||||
|
setRelays(updated);
|
||||||
|
storeRelays(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (index: number): void => {
|
||||||
|
const updated = [...relays];
|
||||||
|
updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled };
|
||||||
|
setRelays(updated);
|
||||||
|
storeRelays(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (endpoint: string): Promise<void> => {
|
||||||
|
setTesting(endpoint);
|
||||||
|
const isOk = await testRelay(endpoint);
|
||||||
|
setTesting(null);
|
||||||
|
if (isOk) {
|
||||||
|
alert('Relais accessible');
|
||||||
|
} else {
|
||||||
|
alert('Relais inaccessible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Réglages relais</h1>
|
||||||
|
<section aria-labelledby="relays-list">
|
||||||
|
<h2 id="relays-list">Liste des relais</h2>
|
||||||
|
{relays.length === 0 ? (
|
||||||
|
<p>Aucun relais configuré</p>
|
||||||
|
) : (
|
||||||
|
<ul role="list">
|
||||||
|
{relays.map((relay, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={relay.enabled}
|
||||||
|
onChange={() => {
|
||||||
|
handleToggle(index);
|
||||||
|
}}
|
||||||
|
aria-label={`Activer ${relay.endpoint}`}
|
||||||
|
/>
|
||||||
|
<span>{relay.endpoint}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleTest(relay.endpoint);
|
||||||
|
}}
|
||||||
|
disabled={testing === relay.endpoint}
|
||||||
|
>
|
||||||
|
{testing === relay.endpoint ? 'Test...' : 'Tester'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleRemove(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="add-relay">
|
||||||
|
<h2 id="add-relay">Ajouter un relais</h2>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newEndpoint}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewEndpoint(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="http://relay.example.com:3019"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button onClick={handleAdd}>Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/')}>Retour</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
userwallet/src/components/ServiceList.tsx
Normal file
43
userwallet/src/components/ServiceList.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import type { ServiceConfig } from '../types/auth';
|
||||||
|
import { sendServiceToggle } from '../utils/iframe';
|
||||||
|
|
||||||
|
interface ServiceListProps {
|
||||||
|
services: ServiceConfig[];
|
||||||
|
onToggle: (serviceId: string, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceList({ services, onToggle }: ServiceListProps): JSX.Element {
|
||||||
|
const handleToggle = (serviceId: string, enabled: boolean): void => {
|
||||||
|
onToggle(serviceId, enabled);
|
||||||
|
sendServiceToggle(serviceId, enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (services.length === 0) {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
<p>Aucun service configuré</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul role="list" aria-label="Liste des services">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.id}>
|
||||||
|
<label htmlFor={`service-${service.id}`}>
|
||||||
|
<input
|
||||||
|
id={`service-${service.id}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={service.enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleToggle(service.id, e.target.checked);
|
||||||
|
}}
|
||||||
|
aria-label={`Activer ${service.name}`}
|
||||||
|
/>
|
||||||
|
<span>{service.name}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
userwallet/src/components/ServiceListScreen.tsx
Normal file
126
userwallet/src/components/ServiceListScreen.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } 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 { ErrorDisplay } from './ErrorDisplay';
|
||||||
|
import { getStoredRelays } from '../utils/relay';
|
||||||
|
import type { ServiceStatus } from '../types/identity';
|
||||||
|
|
||||||
|
export function ServiceListScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
const { error, handleError, clearError } = useErrorHandler();
|
||||||
|
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadServices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadServices = async (): Promise<void> => {
|
||||||
|
if (identity === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const relays = getStoredRelays().filter((r) => r.enabled);
|
||||||
|
if (relays.length === 0) {
|
||||||
|
handleError('Aucun relais activé. Synchronisez d\'abord.', 'NO_RELAYS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphResolver = new GraphResolver();
|
||||||
|
const syncService = new SyncService(relays, graphResolver);
|
||||||
|
|
||||||
|
const start = identity.t0_anniversaire;
|
||||||
|
const end = Date.now();
|
||||||
|
|
||||||
|
await syncService.sync(start, end);
|
||||||
|
|
||||||
|
const serviceStatuses: ServiceStatus[] = [];
|
||||||
|
const services = graphResolver.getServices();
|
||||||
|
for (const service of services) {
|
||||||
|
const membres = graphResolver.getMembres();
|
||||||
|
const membreWithService = membres.find((m) =>
|
||||||
|
m.datajson.services_uuid.includes(service.uuid),
|
||||||
|
);
|
||||||
|
if (membreWithService !== undefined) {
|
||||||
|
const path = graphResolver.resolveLoginPath(service.uuid, membreWithService.uuid);
|
||||||
|
serviceStatuses.push({
|
||||||
|
service_uuid: service.uuid,
|
||||||
|
contrat_complet: path?.statut === 'complet',
|
||||||
|
contrat_valide: path?.statut === 'complet',
|
||||||
|
label: service.datajson.label,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
serviceStatuses.push({
|
||||||
|
service_uuid: service.uuid,
|
||||||
|
contrat_complet: false,
|
||||||
|
contrat_valide: false,
|
||||||
|
label: service.datajson.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setServices(serviceStatuses);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors du chargement des services');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectService = (serviceUuid: string): void => {
|
||||||
|
navigate(`/login?service=${serviceUuid}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Services disponibles</h1>
|
||||||
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
||||||
|
<section aria-labelledby="services-list">
|
||||||
|
<h2 id="services-list">Liste des services</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Chargement...</p>
|
||||||
|
) : services.length === 0 ? (
|
||||||
|
<p>Aucun service disponible. Synchronisez d'abord.</p>
|
||||||
|
) : (
|
||||||
|
<ul role="list">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.service_uuid}>
|
||||||
|
<div>
|
||||||
|
<h3>{service.label ?? service.service_uuid}</h3>
|
||||||
|
<p>
|
||||||
|
<strong>UUID:</strong> {service.service_uuid}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Contrat:</strong>{' '}
|
||||||
|
{service.contrat_complet ? 'Complet et valide' : 'Incomplet'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectService(service.service_uuid);
|
||||||
|
}}
|
||||||
|
disabled={!service.contrat_complet}
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/')}>Retour</button>
|
||||||
|
<button onClick={loadServices} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Chargement...' : 'Rafraîchir'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
userwallet/src/components/SyncScreen.tsx
Normal file
91
userwallet/src/components/SyncScreen.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useIdentity } from '../hooks/useIdentity';
|
||||||
|
import { useErrorHandler } from '../hooks/useErrorHandler';
|
||||||
|
import { ErrorDisplay } from './ErrorDisplay';
|
||||||
|
import { getStoredRelays } from '../utils/relay';
|
||||||
|
import { SyncService } from '../services/syncService';
|
||||||
|
import { GraphResolver } from '../services/graphResolver';
|
||||||
|
|
||||||
|
export function SyncScreen(): JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
const { error, handleError, clearError } = useErrorHandler();
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [stats, setStats] = useState<{
|
||||||
|
messages: number;
|
||||||
|
newMessages: number;
|
||||||
|
decrypted: number;
|
||||||
|
validated: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleSync = async (): Promise<void> => {
|
||||||
|
if (identity === null) {
|
||||||
|
handleError('Identité requise pour synchroniser', 'MISSING_IDENTITY');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const relays = getStoredRelays().filter((r) => r.enabled);
|
||||||
|
if (relays.length === 0) {
|
||||||
|
handleError('Aucun relais activé', 'NO_ENABLED_RELAYS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphResolver = new GraphResolver();
|
||||||
|
const syncService = new SyncService(relays, graphResolver);
|
||||||
|
|
||||||
|
const start = identity.t0_anniversaire;
|
||||||
|
const end = Date.now();
|
||||||
|
|
||||||
|
const result = await syncService.sync(start, end);
|
||||||
|
setStats(result);
|
||||||
|
|
||||||
|
if (result.messages === 0) {
|
||||||
|
handleError('Aucun message récupéré. Vérifiez la connectivité des relais.', 'NO_MESSAGES');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, 'Erreur lors de la synchronisation');
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Synchronisation</h1>
|
||||||
|
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
|
||||||
|
<section aria-labelledby="sync-info">
|
||||||
|
<h2 id="sync-info">Information</h2>
|
||||||
|
{identity !== null && (
|
||||||
|
<p>
|
||||||
|
Fenêtre de scan: depuis {new Date(identity.t0_anniversaire).toLocaleString()}{' '}
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
{stats !== null && (
|
||||||
|
<section aria-labelledby="sync-results">
|
||||||
|
<h2 id="sync-results">Résultats</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Messages récupérés: {stats.messages}</li>
|
||||||
|
<li>Nouveaux messages: {stats.newMessages}</li>
|
||||||
|
<li>Messages déchiffrés: {stats.decrypted}</li>
|
||||||
|
<li>Messages validés: {stats.validated}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/')}>Retour</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
userwallet/src/hooks/useAuth.ts
Normal file
56
userwallet/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
generateKeyPair,
|
||||||
|
signMessage,
|
||||||
|
generateChallenge,
|
||||||
|
type KeyPair,
|
||||||
|
} from '../utils/crypto';
|
||||||
|
import { getStoredKeyPair, storeKeyPair } from '../utils/storage';
|
||||||
|
import type { AuthResponse } from '../types/auth';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [keyPair, setKeyPair] = useState<KeyPair | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredKeyPair();
|
||||||
|
if (stored !== null) {
|
||||||
|
setKeyPair(stored);
|
||||||
|
} else {
|
||||||
|
const newKeyPair = generateKeyPair();
|
||||||
|
storeKeyPair(newKeyPair);
|
||||||
|
setKeyPair(newKeyPair);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signChallenge = useCallback(
|
||||||
|
(challenge: string): AuthResponse | null => {
|
||||||
|
if (keyPair === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const signature = signMessage(challenge, keyPair.privateKey);
|
||||||
|
return {
|
||||||
|
signature,
|
||||||
|
publicKey: keyPair.publicKey,
|
||||||
|
message: challenge,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[keyPair],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createAuthResponse = useCallback((): AuthResponse | null => {
|
||||||
|
if (keyPair === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const challenge = generateChallenge();
|
||||||
|
return signChallenge(challenge);
|
||||||
|
}, [keyPair, signChallenge]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyPair,
|
||||||
|
isLoading,
|
||||||
|
signChallenge,
|
||||||
|
createAuthResponse,
|
||||||
|
};
|
||||||
|
}
|
||||||
69
userwallet/src/hooks/useChannel.ts
Normal file
69
userwallet/src/hooks/useChannel.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
listenToChannel,
|
||||||
|
sendToChannel,
|
||||||
|
isInIframe,
|
||||||
|
type ChannelMessage,
|
||||||
|
type AuthRequestMessage,
|
||||||
|
} from '../utils/iframeChannel';
|
||||||
|
import { useIdentity } from './useIdentity';
|
||||||
|
import { signMessage, generateChallenge } from '../utils/crypto';
|
||||||
|
import type { LoginProof } from '../types/identity';
|
||||||
|
|
||||||
|
export function useChannel() {
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
|
||||||
|
const handleAuthRequest = useCallback(
|
||||||
|
(_message: AuthRequestMessage): void => {
|
||||||
|
if (identity === null) {
|
||||||
|
sendToChannel({
|
||||||
|
type: 'error',
|
||||||
|
payload: {
|
||||||
|
message: 'Identité non disponible',
|
||||||
|
code: 'NO_IDENTITY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = generateChallenge();
|
||||||
|
const signature = signMessage(challenge, identity.privateKey);
|
||||||
|
|
||||||
|
sendToChannel({
|
||||||
|
type: 'auth-response',
|
||||||
|
payload: {
|
||||||
|
signature,
|
||||||
|
publicKey: identity.publicKey,
|
||||||
|
message: challenge,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[identity],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendLoginProof = useCallback((proof: LoginProof): void => {
|
||||||
|
sendToChannel({
|
||||||
|
type: 'login-proof',
|
||||||
|
payload: proof,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInIframe()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = listenToChannel((message: ChannelMessage) => {
|
||||||
|
if (message.type === 'auth-request') {
|
||||||
|
handleAuthRequest(message as AuthRequestMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [handleAuthRequest]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendLoginProof,
|
||||||
|
isInIframe: isInIframe(),
|
||||||
|
};
|
||||||
|
}
|
||||||
47
userwallet/src/hooks/useErrorHandler.ts
Normal file
47
userwallet/src/hooks/useErrorHandler.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface AppError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
details?: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useErrorHandler() {
|
||||||
|
const [error, setError] = useState<AppError | null>(null);
|
||||||
|
|
||||||
|
const handleError = useCallback((err: unknown, context?: string): void => {
|
||||||
|
let errorMessage = 'Une erreur est survenue';
|
||||||
|
let errorCode: string | undefined;
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
errorCode = err.name;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
errorMessage = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context !== undefined) {
|
||||||
|
errorMessage = `${context}: ${errorMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
setError({
|
||||||
|
message: errorMessage,
|
||||||
|
code: errorCode,
|
||||||
|
details: err,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback((): void => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
handleError,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
35
userwallet/src/hooks/useIdentity.ts
Normal file
35
userwallet/src/hooks/useIdentity.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getStoredIdentity, createIdentity, importIdentity } from '../utils/identity';
|
||||||
|
import type { LocalIdentity } from '../types/identity';
|
||||||
|
|
||||||
|
export function useIdentity() {
|
||||||
|
const [identity, setIdentity] = useState<LocalIdentity | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredIdentity();
|
||||||
|
setIdentity(stored);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createNewIdentity = (name?: string): LocalIdentity => {
|
||||||
|
const newIdentity = createIdentity(name);
|
||||||
|
setIdentity(newIdentity);
|
||||||
|
return newIdentity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importExistingIdentity = (seedOrPrivateKey: string, name?: string): LocalIdentity | null => {
|
||||||
|
const imported = importIdentity(seedOrPrivateKey, name);
|
||||||
|
if (imported !== null) {
|
||||||
|
setIdentity(imported);
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
isLoading,
|
||||||
|
createNewIdentity,
|
||||||
|
importExistingIdentity,
|
||||||
|
};
|
||||||
|
}
|
||||||
34
userwallet/src/hooks/useServices.ts
Normal file
34
userwallet/src/hooks/useServices.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getStoredServices, storeServices, updateServiceStatus } from '../utils/storage';
|
||||||
|
import type { ServiceConfig } from '../types/auth';
|
||||||
|
|
||||||
|
export function useServices() {
|
||||||
|
const [services, setServices] = useState<ServiceConfig[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredServices();
|
||||||
|
setServices(stored);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleService = useCallback((serviceId: string, enabled: boolean): void => {
|
||||||
|
updateServiceStatus(serviceId, enabled);
|
||||||
|
setServices((prev) =>
|
||||||
|
prev.map((s) => (s.id === serviceId ? { ...s, enabled } : s)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addService = useCallback((service: ServiceConfig): void => {
|
||||||
|
const updated = [...services, service];
|
||||||
|
storeServices(updated);
|
||||||
|
setServices(updated);
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
services,
|
||||||
|
isLoading,
|
||||||
|
toggleService,
|
||||||
|
addService,
|
||||||
|
};
|
||||||
|
}
|
||||||
162
userwallet/src/index.css
Normal file
162
userwallet/src/index.css
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[role="list"] {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
padding: var(--spacing-md) var(--spacing-xl);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"]:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"]:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
userwallet/src/main.tsx
Normal file
10
userwallet/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
266
userwallet/src/services/graphResolver.ts
Normal file
266
userwallet/src/services/graphResolver.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import type {
|
||||||
|
Service,
|
||||||
|
Contrat,
|
||||||
|
Champ,
|
||||||
|
Action,
|
||||||
|
ActionLogin,
|
||||||
|
Membre,
|
||||||
|
Pair,
|
||||||
|
} from '../types/contract';
|
||||||
|
import type { LoginPath, SignatureRequirement } from '../types/identity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache local pour les objets du graphe.
|
||||||
|
*/
|
||||||
|
interface GraphCache {
|
||||||
|
services: Map<string, Service>;
|
||||||
|
contrats: Map<string, Contrat>;
|
||||||
|
champs: Map<string, Champ>;
|
||||||
|
actions: Map<string, Action>;
|
||||||
|
membres: Map<string, Membre>;
|
||||||
|
pairs: Map<string, Pair>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de résolution du graphe contractuel.
|
||||||
|
*/
|
||||||
|
export class GraphResolver {
|
||||||
|
private cache: GraphCache;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.cache = {
|
||||||
|
services: new Map(),
|
||||||
|
contrats: new Map(),
|
||||||
|
champs: new Map(),
|
||||||
|
actions: new Map(),
|
||||||
|
membres: new Map(),
|
||||||
|
pairs: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a service to the cache.
|
||||||
|
*/
|
||||||
|
addService(service: Service): void {
|
||||||
|
this.cache.services.set(service.uuid, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a contrat to the cache.
|
||||||
|
*/
|
||||||
|
addContrat(contrat: Contrat): void {
|
||||||
|
this.cache.contrats.set(contrat.uuid, contrat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a champ to the cache.
|
||||||
|
*/
|
||||||
|
addChamp(champ: Champ): void {
|
||||||
|
this.cache.champs.set(champ.uuid, champ);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an action to the cache.
|
||||||
|
*/
|
||||||
|
addAction(action: Action): void {
|
||||||
|
this.cache.actions.set(action.uuid, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a membre to the cache.
|
||||||
|
*/
|
||||||
|
addMembre(membre: Membre): void {
|
||||||
|
this.cache.membres.set(membre.uuid, membre);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a pair to the cache.
|
||||||
|
*/
|
||||||
|
addPair(pair: Pair): void {
|
||||||
|
this.cache.pairs.set(pair.uuid, pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all services from cache.
|
||||||
|
*/
|
||||||
|
getServices(): Service[] {
|
||||||
|
return Array.from(this.cache.services.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all membres from cache.
|
||||||
|
*/
|
||||||
|
getMembres(): Membre[] {
|
||||||
|
return Array.from(this.cache.membres.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve login path: Service → Contrat → Champ → ActionLogin → Membre → Pair.
|
||||||
|
*/
|
||||||
|
resolveLoginPath(
|
||||||
|
serviceUuid: string,
|
||||||
|
membreUuid: string,
|
||||||
|
): LoginPath | null {
|
||||||
|
const service = this.cache.services.get(serviceUuid);
|
||||||
|
if (service === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contrat = this.cache.contrats.get(service.contrat_uuid);
|
||||||
|
if (contrat === undefined) {
|
||||||
|
return {
|
||||||
|
service_uuid: serviceUuid,
|
||||||
|
contrat_uuid: [],
|
||||||
|
action_login_uuid: '',
|
||||||
|
membre_uuid: membreUuid,
|
||||||
|
pairs_attendus: [],
|
||||||
|
signatures_requises: [],
|
||||||
|
statut: 'incomplet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const champs = Array.from(this.cache.champs.values()).filter((c) =>
|
||||||
|
c.contrats_parents_uuid.includes(service.contrat_uuid),
|
||||||
|
);
|
||||||
|
|
||||||
|
const membre = this.cache.membres.get(membreUuid);
|
||||||
|
if (membre === undefined) {
|
||||||
|
return {
|
||||||
|
service_uuid: serviceUuid,
|
||||||
|
contrat_uuid: [service.contrat_uuid],
|
||||||
|
champ_uuid: champs.map((c) => c.uuid),
|
||||||
|
action_login_uuid: '',
|
||||||
|
membre_uuid: membreUuid,
|
||||||
|
pairs_attendus: [],
|
||||||
|
signatures_requises: [],
|
||||||
|
statut: 'incomplet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLogin = this.findActionLogin(membre.actions_parents_uuid);
|
||||||
|
if (actionLogin === null) {
|
||||||
|
return {
|
||||||
|
service_uuid: serviceUuid,
|
||||||
|
contrat_uuid: [service.contrat_uuid],
|
||||||
|
champ_uuid: champs.map((c) => c.uuid),
|
||||||
|
action_login_uuid: '',
|
||||||
|
membre_uuid: membreUuid,
|
||||||
|
pairs_attendus: [],
|
||||||
|
signatures_requises: [],
|
||||||
|
statut: 'incomplet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairsAttendus = Array.from(this.cache.pairs.values())
|
||||||
|
.filter((p) => p.membres_parents_uuid.includes(membreUuid))
|
||||||
|
.map((p) => p.uuid);
|
||||||
|
|
||||||
|
const signaturesRequises = this.computeRequiredSignatures(
|
||||||
|
actionLogin,
|
||||||
|
membreUuid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statut =
|
||||||
|
pairsAttendus.length > 0 && signaturesRequises.length > 0
|
||||||
|
? 'complet'
|
||||||
|
: 'incomplet';
|
||||||
|
|
||||||
|
return {
|
||||||
|
service_uuid: serviceUuid,
|
||||||
|
contrat_uuid: [service.contrat_uuid],
|
||||||
|
champ_uuid: champs.map((c) => c.uuid),
|
||||||
|
action_login_uuid: actionLogin.uuid,
|
||||||
|
membre_uuid: membreUuid,
|
||||||
|
pairs_attendus: pairsAttendus,
|
||||||
|
signatures_requises: signaturesRequises,
|
||||||
|
statut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find action login from action UUIDs.
|
||||||
|
*/
|
||||||
|
private findActionLogin(actionUuids: string[]): ActionLogin | null {
|
||||||
|
for (const uuid of actionUuids) {
|
||||||
|
const action = this.cache.actions.get(uuid);
|
||||||
|
if (action !== undefined && this.isActionLogin(action)) {
|
||||||
|
return action as ActionLogin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action is a login action.
|
||||||
|
*/
|
||||||
|
private isActionLogin(action: Action): boolean {
|
||||||
|
return (
|
||||||
|
action.types.types_names_chiffres.includes('login') ||
|
||||||
|
action.types.types_uuid.some((uuid) => uuid.includes('login'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute required signatures from validators.
|
||||||
|
*/
|
||||||
|
private computeRequiredSignatures(
|
||||||
|
action: Action,
|
||||||
|
membreUuid: string,
|
||||||
|
): SignatureRequirement[] {
|
||||||
|
const requirements: SignatureRequirement[] = [];
|
||||||
|
|
||||||
|
for (const membreRole of action.validateurs_action.membres_du_role) {
|
||||||
|
if (membreRole.membre_uuid === membreUuid) {
|
||||||
|
for (const sigReq of membreRole.signatures_obligatoires) {
|
||||||
|
requirements.push({
|
||||||
|
membre_uuid: sigReq.membre_uuid,
|
||||||
|
pair_uuid: undefined,
|
||||||
|
cle_publique: sigReq.cle_publique,
|
||||||
|
cardinalite_minimale: sigReq.cardinalite_minimale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that all required parents exist (at least 1 constraint).
|
||||||
|
*/
|
||||||
|
validateParentsConstraints(): {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const champ of this.cache.champs.values()) {
|
||||||
|
if (champ.contrats_parents_uuid.length === 0) {
|
||||||
|
errors.push(`Champ ${champ.uuid} has no parent contrat`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const action of this.cache.actions.values()) {
|
||||||
|
if (action.contrats_parents_uuid.length === 0) {
|
||||||
|
errors.push(`Action ${action.uuid} has no parent contrat`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const membre of this.cache.membres.values()) {
|
||||||
|
if (membre.actions_parents_uuid.length === 0) {
|
||||||
|
errors.push(`Membre ${membre.uuid} has no parent action`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pair of this.cache.pairs.values()) {
|
||||||
|
if (pair.membres_parents_uuid.length === 0) {
|
||||||
|
errors.push(`Pair ${pair.uuid} has no parent membre`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
128
userwallet/src/services/loginBuilder.ts
Normal file
128
userwallet/src/services/loginBuilder.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { generateUuid } from '../utils/bip32';
|
||||||
|
import { hashStringAsync } from '../utils/canonical';
|
||||||
|
import { signMessage } from '../utils/crypto';
|
||||||
|
import { encryptForAll, decryptForAll } from '../utils/encryption';
|
||||||
|
import type { LocalIdentity } from '../types/identity';
|
||||||
|
import type { LoginChallenge, LoginProof } from '../types/identity';
|
||||||
|
import type { MsgChiffre } from '../types/message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for building and publishing login messages.
|
||||||
|
*/
|
||||||
|
export class LoginBuilder {
|
||||||
|
private relays: string[];
|
||||||
|
private encryptionKey: Uint8Array;
|
||||||
|
|
||||||
|
constructor(_identity: LocalIdentity, relays: string[]) {
|
||||||
|
this.relays = relays;
|
||||||
|
this.encryptionKey = this.generateEncryptionKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random encryption key for "publish to all".
|
||||||
|
*/
|
||||||
|
private generateEncryptionKey(): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a login challenge message.
|
||||||
|
*/
|
||||||
|
async buildChallenge(
|
||||||
|
serviceUuid: string,
|
||||||
|
typeUuid: string,
|
||||||
|
_actionLoginUuid: string,
|
||||||
|
_membreUuid: string,
|
||||||
|
): Promise<LoginChallenge> {
|
||||||
|
const nonce = generateUuid();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const datajsonPublic = {
|
||||||
|
services_uuid: [serviceUuid],
|
||||||
|
types_uuid: [typeUuid],
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageBase = {
|
||||||
|
uuid: generateUuid(),
|
||||||
|
version: '1.0',
|
||||||
|
types: {
|
||||||
|
types_uuid: [typeUuid],
|
||||||
|
types_names_chiffres: 'login',
|
||||||
|
},
|
||||||
|
datajson: datajsonPublic,
|
||||||
|
timestamp,
|
||||||
|
liste_relais: this.relays,
|
||||||
|
version_logicielle: '1.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageJson = JSON.stringify(messageBase);
|
||||||
|
const hash = await hashStringAsync(messageJson, 'sha256');
|
||||||
|
|
||||||
|
const { encrypted } = await encryptForAll(messageJson, this.encryptionKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
message_chiffre: encrypted,
|
||||||
|
datajson_public: datajsonPublic,
|
||||||
|
nonce,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a challenge with a pair's private key.
|
||||||
|
*/
|
||||||
|
signChallenge(
|
||||||
|
challenge: LoginChallenge,
|
||||||
|
pairUuid: string,
|
||||||
|
pairPrivateKey: string,
|
||||||
|
): string {
|
||||||
|
const messageToSign = `${challenge.hash}-${challenge.nonce}-${pairUuid}`;
|
||||||
|
return signMessage(messageToSign, pairPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build login proof with signatures.
|
||||||
|
*/
|
||||||
|
async buildProof(
|
||||||
|
challenge: LoginChallenge,
|
||||||
|
signatures: Array<{
|
||||||
|
signature: string;
|
||||||
|
cle_publique: string;
|
||||||
|
nonce: string;
|
||||||
|
pair_uuid: string;
|
||||||
|
}>,
|
||||||
|
): Promise<LoginProof> {
|
||||||
|
return {
|
||||||
|
challenge,
|
||||||
|
signatures,
|
||||||
|
statut: 'en_attente',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert challenge to MsgChiffre for publication.
|
||||||
|
*/
|
||||||
|
challengeToMsgChiffre(challenge: LoginChallenge): MsgChiffre {
|
||||||
|
return {
|
||||||
|
hash: challenge.hash,
|
||||||
|
message_chiffre: challenge.message_chiffre,
|
||||||
|
datajson_public: challenge.datajson_public,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get encryption key for sharing (via ECDH messages).
|
||||||
|
*/
|
||||||
|
getEncryptionKey(): Uint8Array {
|
||||||
|
return this.encryptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a message encrypted with encryptForAll.
|
||||||
|
*/
|
||||||
|
async decryptMessage(encrypted: string, iv: string): Promise<string> {
|
||||||
|
return decryptForAll(encrypted, iv, this.encryptionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
userwallet/src/services/syncService.ts
Normal file
202
userwallet/src/services/syncService.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
getMessagesChiffres,
|
||||||
|
getSignatures,
|
||||||
|
getKeys,
|
||||||
|
} from '../utils/relay';
|
||||||
|
import type { RelayConfig } from '../types/identity';
|
||||||
|
import type { MessageBase, MsgChiffre, MsgSignature, MsgCle } from '../types/message';
|
||||||
|
import { GraphResolver } from './graphResolver';
|
||||||
|
import { verifyMessageHash, verifyMessageSignatures, verifyTimestamp } from '../utils/verification';
|
||||||
|
import { HashCache } from '../utils/cache';
|
||||||
|
import type {
|
||||||
|
Service,
|
||||||
|
Contrat,
|
||||||
|
Champ,
|
||||||
|
Action,
|
||||||
|
Membre,
|
||||||
|
Pair,
|
||||||
|
} from '../types/contract';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for synchronizing messages from relays.
|
||||||
|
*/
|
||||||
|
export class SyncService {
|
||||||
|
private relays: RelayConfig[];
|
||||||
|
private graphResolver: GraphResolver;
|
||||||
|
private hashCache: HashCache;
|
||||||
|
|
||||||
|
constructor(relays: RelayConfig[], graphResolver: GraphResolver) {
|
||||||
|
this.relays = relays;
|
||||||
|
this.graphResolver = graphResolver;
|
||||||
|
this.hashCache = new HashCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize messages from all enabled relays.
|
||||||
|
*/
|
||||||
|
async sync(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
serviceUuid?: string,
|
||||||
|
): Promise<{
|
||||||
|
messages: number;
|
||||||
|
newMessages: number;
|
||||||
|
decrypted: number;
|
||||||
|
validated: number;
|
||||||
|
}> {
|
||||||
|
let messages = 0;
|
||||||
|
let newMessages = 0;
|
||||||
|
let decrypted = 0;
|
||||||
|
let validated = 0;
|
||||||
|
|
||||||
|
for (const relay of this.relays) {
|
||||||
|
if (!relay.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msgs = await getMessagesChiffres(
|
||||||
|
relay.endpoint,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
serviceUuid,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const msg of msgs) {
|
||||||
|
messages++;
|
||||||
|
if (!this.hashCache.hasSeen(msg.hash)) {
|
||||||
|
newMessages++;
|
||||||
|
this.hashCache.markSeen(msg.hash);
|
||||||
|
|
||||||
|
const decryptedMsg = await this.tryDecrypt(msg);
|
||||||
|
if (decryptedMsg !== null) {
|
||||||
|
decrypted++;
|
||||||
|
const isValid = await this.validateMessage(decryptedMsg);
|
||||||
|
if (isValid) {
|
||||||
|
validated++;
|
||||||
|
this.updateGraph(decryptedMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error syncing from ${relay.endpoint}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages, newMessages, decrypted, validated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to decrypt a message (placeholder - implement proper decryption).
|
||||||
|
*/
|
||||||
|
private async tryDecrypt(msg: MsgChiffre): Promise<unknown | null> {
|
||||||
|
try {
|
||||||
|
const keys = await this.fetchKeys(msg.hash);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const decrypted = atob(msg.message_chiffre);
|
||||||
|
return JSON.parse(decrypted);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch decryption keys for a message hash.
|
||||||
|
*/
|
||||||
|
private async fetchKeys(hash: string): Promise<MsgCle[]> {
|
||||||
|
const allKeys: MsgCle[] = [];
|
||||||
|
for (const relay of this.relays) {
|
||||||
|
if (!relay.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const keys = await getKeys(relay.endpoint, hash);
|
||||||
|
allKeys.push(...keys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching keys from ${relay.endpoint}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch signatures for a message hash.
|
||||||
|
*/
|
||||||
|
async fetchSignatures(hash: string): Promise<MsgSignature[]> {
|
||||||
|
const allSignatures: MsgSignature[] = [];
|
||||||
|
for (const relay of this.relays) {
|
||||||
|
if (!relay.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sigs = await getSignatures(relay.endpoint, hash);
|
||||||
|
allSignatures.push(...sigs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching signatures from ${relay.endpoint}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allSignatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a decrypted message (check hash, signatures, timestamp, etc.).
|
||||||
|
*/
|
||||||
|
private async validateMessage(decrypted: unknown): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const msg = decrypted as MessageBase;
|
||||||
|
if (msg.hash === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashValid = await verifyMessageHash(msg);
|
||||||
|
if (!hashValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyTimestamp(msg.timestamp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatures = await this.fetchSignatures(msg.hash.hash_value);
|
||||||
|
if (signatures.length > 0) {
|
||||||
|
const { valid } = verifyMessageSignatures(msg, signatures.map((s) => s.signature));
|
||||||
|
if (valid.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update graph cache with decrypted message.
|
||||||
|
*/
|
||||||
|
private updateGraph(decrypted: unknown): void {
|
||||||
|
try {
|
||||||
|
const msg = decrypted as MessageBase;
|
||||||
|
const typeNames = msg.types.types_names_chiffres.toLowerCase();
|
||||||
|
|
||||||
|
if (typeNames.includes('service')) {
|
||||||
|
this.graphResolver.addService(msg as unknown as Service);
|
||||||
|
} else if (typeNames.includes('contrat')) {
|
||||||
|
this.graphResolver.addContrat(msg as unknown as Contrat);
|
||||||
|
} else if (typeNames.includes('champ')) {
|
||||||
|
this.graphResolver.addChamp(msg as unknown as Champ);
|
||||||
|
} else if (typeNames.includes('action')) {
|
||||||
|
this.graphResolver.addAction(msg as unknown as Action);
|
||||||
|
} else if (typeNames.includes('membre')) {
|
||||||
|
this.graphResolver.addMembre(msg as unknown as Membre);
|
||||||
|
} else if (typeNames.includes('pair')) {
|
||||||
|
this.graphResolver.addPair(msg as unknown as Pair);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating graph:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
userwallet/src/types/auth.ts
Normal file
52
userwallet/src/types/auth.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Legacy interface for iframe communication (kept for backward compatibility).
|
||||||
|
* @deprecated Will be replaced by the full contract-based system.
|
||||||
|
*/
|
||||||
|
export interface ServiceConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthChallenge {
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
signature: string;
|
||||||
|
publicKey: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IframeMessage {
|
||||||
|
type: 'auth-request' | 'auth-response' | 'service-toggle' | 'service-status';
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequestMessage extends IframeMessage {
|
||||||
|
type: 'auth-request';
|
||||||
|
payload: {
|
||||||
|
serviceId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponseMessage extends IframeMessage {
|
||||||
|
type: 'auth-response';
|
||||||
|
payload: AuthResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceToggleMessage extends IframeMessage {
|
||||||
|
type: 'service-toggle';
|
||||||
|
payload: {
|
||||||
|
serviceId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatusMessage extends IframeMessage {
|
||||||
|
type: 'service-status';
|
||||||
|
payload: {
|
||||||
|
services: ServiceConfig[];
|
||||||
|
};
|
||||||
|
}
|
||||||
70
userwallet/src/types/contract.ts
Normal file
70
userwallet/src/types/contract.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { MessageAValider, MessageBase } from './message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service object - root of authentication.
|
||||||
|
*/
|
||||||
|
export interface Service extends MessageBase {
|
||||||
|
contrat_uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract object - identity contract.
|
||||||
|
*/
|
||||||
|
export interface Contrat extends MessageAValider {
|
||||||
|
// Inherits MessageAValider (MessageBase + validateurs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field object - attached to contracts.
|
||||||
|
*/
|
||||||
|
export interface Champ extends MessageAValider {
|
||||||
|
contrats_parents_uuid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action object - executable action in contract context.
|
||||||
|
*/
|
||||||
|
export interface Action extends MessageAValider {
|
||||||
|
validateurs_action: Validateurs;
|
||||||
|
contrats_parents_uuid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login action - specialization of Action.
|
||||||
|
*/
|
||||||
|
export interface ActionLogin extends Action {
|
||||||
|
// types_names_chiffres includes "login"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member object - identity authorized to execute actions.
|
||||||
|
*/
|
||||||
|
export interface Membre extends MessageAValider {
|
||||||
|
actions_parents_uuid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair object - device/instance for mFA signatures.
|
||||||
|
*/
|
||||||
|
export interface Pair extends MessageBase {
|
||||||
|
membres_parents_uuid: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validators interface (re-exported from message.ts for convenience).
|
||||||
|
*/
|
||||||
|
export interface Validateurs {
|
||||||
|
membres_du_role: MembreRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembreRole {
|
||||||
|
membre_uuid: string;
|
||||||
|
signatures_obligatoires: SignatureRequirement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureRequirement {
|
||||||
|
membre_uuid: string;
|
||||||
|
cle_publique?: string;
|
||||||
|
cardinalite_minimale?: number;
|
||||||
|
dependances?: string[];
|
||||||
|
}
|
||||||
92
userwallet/src/types/identity.ts
Normal file
92
userwallet/src/types/identity.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Local identity with secp256k1 key pair.
|
||||||
|
*/
|
||||||
|
export interface LocalIdentity {
|
||||||
|
uuid: string;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
name?: string;
|
||||||
|
t0_anniversaire: number;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay configuration.
|
||||||
|
*/
|
||||||
|
export interface RelayConfig {
|
||||||
|
endpoint: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
last_sync?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair configuration (local device or remote).
|
||||||
|
*/
|
||||||
|
export interface PairConfig {
|
||||||
|
uuid: string;
|
||||||
|
membres_parents_uuid: string[];
|
||||||
|
is_local: boolean;
|
||||||
|
can_sign: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service status in local cache.
|
||||||
|
*/
|
||||||
|
export interface ServiceStatus {
|
||||||
|
service_uuid: string;
|
||||||
|
contrat_complet: boolean;
|
||||||
|
contrat_valide: boolean;
|
||||||
|
dernier_sync?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graph resolution result for login path.
|
||||||
|
*/
|
||||||
|
export interface LoginPath {
|
||||||
|
service_uuid: string;
|
||||||
|
contrat_uuid: string[];
|
||||||
|
champ_uuid?: string[];
|
||||||
|
action_login_uuid: string;
|
||||||
|
membre_uuid: string;
|
||||||
|
pairs_attendus: string[];
|
||||||
|
signatures_requises: SignatureRequirement[];
|
||||||
|
statut: 'complet' | 'incomplet' | 'invalide';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureRequirement {
|
||||||
|
membre_uuid: string;
|
||||||
|
pair_uuid?: string;
|
||||||
|
cle_publique?: string;
|
||||||
|
cardinalite_minimale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login challenge message.
|
||||||
|
*/
|
||||||
|
export interface LoginChallenge {
|
||||||
|
hash: string;
|
||||||
|
message_chiffre: string;
|
||||||
|
datajson_public: {
|
||||||
|
services_uuid: string[];
|
||||||
|
types_uuid: string[];
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
nonce: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login proof with signatures.
|
||||||
|
*/
|
||||||
|
export interface LoginProof {
|
||||||
|
challenge: LoginChallenge;
|
||||||
|
signatures: Array<{
|
||||||
|
signature: string;
|
||||||
|
cle_publique: string;
|
||||||
|
nonce: string;
|
||||||
|
pair_uuid: string;
|
||||||
|
}>;
|
||||||
|
statut: 'en_attente' | 'publie' | 'valide' | 'invalide';
|
||||||
|
}
|
||||||
125
userwallet/src/types/message.ts
Normal file
125
userwallet/src/types/message.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Base structure for all messages in the system.
|
||||||
|
* All messages must include these fields.
|
||||||
|
*/
|
||||||
|
export interface MessageBase {
|
||||||
|
uuid: string;
|
||||||
|
version: string;
|
||||||
|
types: {
|
||||||
|
types_uuid: string[];
|
||||||
|
types_names_chiffres: string;
|
||||||
|
};
|
||||||
|
cles_de_chiffrement?: EncryptionKeys;
|
||||||
|
datajson: DataJson;
|
||||||
|
timestamp: number;
|
||||||
|
liste_relais: string[];
|
||||||
|
version_logicielle: string;
|
||||||
|
hash?: Hash;
|
||||||
|
signatures?: Signature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash of canonical JSON (without hash, signatures, encryption keys).
|
||||||
|
*/
|
||||||
|
export interface Hash {
|
||||||
|
algo: string;
|
||||||
|
hash_value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cryptographic signature with secp256k1.
|
||||||
|
*/
|
||||||
|
export interface Signature {
|
||||||
|
hash: string;
|
||||||
|
cle_publique: string;
|
||||||
|
signature: string;
|
||||||
|
nonce: string;
|
||||||
|
materiel?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encryption keys with parameters and algorithm.
|
||||||
|
*/
|
||||||
|
export interface EncryptionKeys {
|
||||||
|
algo: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
cle_chiffree?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public data JSON with required and optional fields.
|
||||||
|
*/
|
||||||
|
export interface DataJson {
|
||||||
|
services_uuid: string[];
|
||||||
|
types_uuid: string[];
|
||||||
|
label?: string;
|
||||||
|
description_longue?: string;
|
||||||
|
description_courte?: string;
|
||||||
|
photo?: string;
|
||||||
|
image?: string;
|
||||||
|
banniere?: string;
|
||||||
|
url?: string;
|
||||||
|
paragraphes?: Array<{
|
||||||
|
texte: string;
|
||||||
|
image?: string;
|
||||||
|
}>;
|
||||||
|
tarif?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message requiring validation with validators.
|
||||||
|
*/
|
||||||
|
export interface MessageAValider extends MessageBase {
|
||||||
|
validateurs: Validateurs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validators defining signature requirements.
|
||||||
|
*/
|
||||||
|
export interface Validateurs {
|
||||||
|
membres_du_role: MembreRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member role with required signatures.
|
||||||
|
*/
|
||||||
|
export interface MembreRole {
|
||||||
|
membre_uuid: string;
|
||||||
|
signatures_obligatoires: SignatureRequirement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature requirement for a member.
|
||||||
|
*/
|
||||||
|
export interface SignatureRequirement {
|
||||||
|
membre_uuid: string;
|
||||||
|
cle_publique?: string;
|
||||||
|
cardinalite_minimale?: number;
|
||||||
|
dependances?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published encrypted message (without signatures and keys).
|
||||||
|
*/
|
||||||
|
export interface MsgChiffre {
|
||||||
|
hash: string;
|
||||||
|
message_chiffre: string;
|
||||||
|
datajson_public: DataJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate signature message.
|
||||||
|
*/
|
||||||
|
export interface MsgSignature {
|
||||||
|
signature: Signature;
|
||||||
|
hash_cible?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual decryption message with ECDH material.
|
||||||
|
*/
|
||||||
|
export interface MsgCle {
|
||||||
|
hash_message: string;
|
||||||
|
cle_de_chiffrement_message: EncryptionKeys;
|
||||||
|
df_ecdh_scannable: string;
|
||||||
|
}
|
||||||
312
userwallet/src/utils/bip32.ts
Normal file
312
userwallet/src/utils/bip32.ts
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BIP32 wordlist (English - first 2048 words).
|
||||||
|
* In production, use a complete BIP39 wordlist.
|
||||||
|
*/
|
||||||
|
const BIP32_WORDLIST = [
|
||||||
|
'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract',
|
||||||
|
'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid',
|
||||||
|
'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actual', 'adapt',
|
||||||
|
'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance', 'advice',
|
||||||
|
'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent', 'agree',
|
||||||
|
'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol',
|
||||||
|
'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha',
|
||||||
|
'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among', 'amount',
|
||||||
|
'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry', 'animal',
|
||||||
|
'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique', 'anxiety',
|
||||||
|
'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'area',
|
||||||
|
'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange',
|
||||||
|
'arrest', 'arrive', 'arrow', 'art', 'article', 'artist', 'artwork', 'ask',
|
||||||
|
'aspect', 'assault', 'asset', 'assist', 'assume', 'asthma', 'athlete', 'atom',
|
||||||
|
'attack', 'attend', 'attitude', 'attract', 'auction', 'audit', 'august', 'aunt',
|
||||||
|
'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake', 'aware',
|
||||||
|
'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon',
|
||||||
|
'badge', 'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner',
|
||||||
|
'bar', 'barely', 'bargain', 'barrel', 'base', 'basic', 'basket', 'battle',
|
||||||
|
'beach', 'bean', 'beauty', 'because', 'become', 'beef', 'before', 'begin',
|
||||||
|
'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit', 'best',
|
||||||
|
'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind',
|
||||||
|
'biology', 'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket',
|
||||||
|
'blast', 'bleak', 'bless', 'blind', 'blood', 'blossom', 'blow', 'blue',
|
||||||
|
'blur', 'blush', 'board', 'boat', 'body', 'boil', 'bomb', 'bone',
|
||||||
|
'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss', 'bottom',
|
||||||
|
'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave',
|
||||||
|
'bread', 'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk',
|
||||||
|
'broccoli', 'broken', 'bronze', 'broom', 'brother', 'brown', 'brush', 'bubble',
|
||||||
|
'buddy', 'budget', 'buffalo', 'build', 'bulb', 'bulk', 'bullet', 'bundle',
|
||||||
|
'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'busy', 'butter',
|
||||||
|
'buyer', 'buzz', 'cabbage', 'cabin', 'cable', 'cactus', 'cage', 'cake',
|
||||||
|
'call', 'calm', 'camera', 'camp', 'can', 'canal', 'cancel', 'candy',
|
||||||
|
'cannon', 'canoe', 'canvas', 'canyon', 'capable', 'capital', 'captain', 'car',
|
||||||
|
'carbon', 'card', 'care', 'career', 'careful', 'careless', 'cargo', 'carpet',
|
||||||
|
'carry', 'cart', 'case', 'cash', 'casino', 'cast', 'casual', 'cat',
|
||||||
|
'catalog', 'catch', 'category', 'cattle', 'caught', 'cause', 'caution', 'cave',
|
||||||
|
'ceiling', 'celery', 'cement', 'census', 'century', 'cereal', 'certain', 'chair',
|
||||||
|
'chalk', 'champion', 'change', 'chaos', 'chapter', 'charge', 'chase', 'chat',
|
||||||
|
'cheap', 'check', 'cheese', 'chef', 'cherry', 'chest', 'chicken', 'chief',
|
||||||
|
'child', 'chimney', 'choice', 'choose', 'chronic', 'chuckle', 'chunk', 'churn',
|
||||||
|
'cigar', 'cinnamon', 'circle', 'citizen', 'city', 'civil', 'claim', 'clamp',
|
||||||
|
'clarify', 'claw', 'clay', 'clean', 'clerk', 'clever', 'click', 'client',
|
||||||
|
'cliff', 'climb', 'clinic', 'clip', 'clock', 'clog', 'close', 'cloth',
|
||||||
|
'cloud', 'clown', 'club', 'clump', 'cluster', 'clutch', 'coach', 'coast',
|
||||||
|
'coconut', 'code', 'coffee', 'coil', 'coin', 'collect', 'color', 'column',
|
||||||
|
'combine', 'come', 'comfort', 'comic', 'common', 'company', 'concert', 'conduct',
|
||||||
|
'confirm', 'congress', 'connect', 'consider', 'control', 'convince', 'cook', 'cool',
|
||||||
|
'copper', 'copy', 'coral', 'core', 'corn', 'correct', 'cost', 'cotton',
|
||||||
|
'couch', 'country', 'couple', 'course', 'cousin', 'cover', 'coyote', 'crack',
|
||||||
|
'cradle', 'craft', 'cram', 'crane', 'crash', 'crater', 'crawl', 'crazy',
|
||||||
|
'cream', 'credit', 'creek', 'crew', 'cricket', 'crime', 'crisp', 'critic',
|
||||||
|
'crop', 'cross', 'crouch', 'crowd', 'crucial', 'cruel', 'cruise', 'crumble',
|
||||||
|
'crunch', 'crush', 'cry', 'crystal', 'cube', 'culture', 'cup', 'cupboard',
|
||||||
|
'curious', 'current', 'curtain', 'curve', 'cushion', 'custom', 'cute', 'cycle',
|
||||||
|
'dad', 'damage', 'damp', 'dance', 'danger', 'daring', 'dark', 'dash',
|
||||||
|
'daughter', 'dawn', 'day', 'deal', 'debate', 'debris', 'decade', 'december',
|
||||||
|
'decide', 'decline', 'decorate', 'decrease', 'deer', 'defense', 'define', 'defy',
|
||||||
|
'degree', 'delay', 'deliver', 'demand', 'demise', 'denial', 'dentist', 'deny',
|
||||||
|
'depart', 'depend', 'deposit', 'depth', 'deputy', 'derive', 'describe', 'desert',
|
||||||
|
'design', 'desk', 'despair', 'destroy', 'detail', 'detect', 'develop', 'device',
|
||||||
|
'devote', 'diagram', 'dial', 'diamond', 'diary', 'dice', 'diesel', 'diet',
|
||||||
|
'differ', 'digital', 'dignity', 'dilemma', 'dinner', 'dinosaur', 'direct', 'dirt',
|
||||||
|
'disagree', 'discover', 'disease', 'dish', 'dismiss', 'disorder', 'display', 'distance',
|
||||||
|
'divert', 'divide', 'divorce', 'dizzy', 'doctor', 'document', 'dog', 'doll',
|
||||||
|
'dolphin', 'domain', 'donate', 'donkey', 'donor', 'door', 'dose', 'double',
|
||||||
|
'dove', 'draft', 'dragon', 'drama', 'dramatic', 'drastic', 'draw', 'dream',
|
||||||
|
'dress', 'drift', 'drill', 'drink', 'drip', 'drive', 'drop', 'drum',
|
||||||
|
'dry', 'duck', 'dumb', 'dune', 'during', 'dust', 'dutch', 'duty',
|
||||||
|
'dwarf', 'dynamic', 'eager', 'eagle', 'early', 'earn', 'earth', 'easily',
|
||||||
|
'east', 'easy', 'echo', 'ecology', 'economy', 'edge', 'edit', 'educate',
|
||||||
|
'effort', 'egg', 'eight', 'either', 'elbow', 'elder', 'electric', 'elegant',
|
||||||
|
'element', 'elephant', 'elevator', 'elite', 'else', 'embark', 'embody', 'embrace',
|
||||||
|
'emerge', 'emotion', 'employ', 'empower', 'empty', 'enable', 'enact', 'end',
|
||||||
|
'endless', 'endorse', 'enemy', 'energy', 'enforce', 'engage', 'engine', 'enhance',
|
||||||
|
'enjoy', 'enlist', 'enough', 'enrich', 'enroll', 'ensure', 'enter', 'entire',
|
||||||
|
'entry', 'envelope', 'episode', 'equal', 'equip', 'era', 'erase', 'erode',
|
||||||
|
'erosion', 'error', 'erupt', 'escape', 'essay', 'essence', 'estate', 'eternal',
|
||||||
|
'ethics', 'evidence', 'evil', 'evoke', 'evolve', 'exact', 'example', 'exceed',
|
||||||
|
'excel', 'exception', 'excess', 'exchange', 'excite', 'exclude', 'excuse', 'execute',
|
||||||
|
'exercise', 'exhaust', 'exhibit', 'exile', 'exist', 'exit', 'exotic', 'expand',
|
||||||
|
'expect', 'expire', 'explain', 'expose', 'express', 'extend', 'extra', 'eye',
|
||||||
|
'eyebrow', 'fabric', 'face', 'faculty', 'fade', 'faint', 'faith', 'fall',
|
||||||
|
'false', 'fame', 'family', 'famous', 'fan', 'fancy', 'fantasy', 'farm',
|
||||||
|
'fashion', 'fat', 'fatal', 'father', 'fatigue', 'fault', 'favorite', 'feature',
|
||||||
|
'february', 'federal', 'fee', 'feed', 'feel', 'female', 'fence', 'festival',
|
||||||
|
'fetch', 'fever', 'few', 'fiber', 'fiction', 'field', 'fierce', 'fifteen',
|
||||||
|
'fifty', 'fight', 'file', 'film', 'filter', 'final', 'find', 'fine',
|
||||||
|
'finger', 'finish', 'fire', 'firm', 'first', 'fiscal', 'fish', 'fit',
|
||||||
|
'fitness', 'fix', 'flag', 'flame', 'flash', 'flat', 'flavor', 'flee',
|
||||||
|
'flight', 'flip', 'float', 'flock', 'floor', 'flower', 'fluid', 'flush',
|
||||||
|
'fly', 'foam', 'focus', 'fog', 'foil', 'fold', 'follow', 'food',
|
||||||
|
'foot', 'force', 'forest', 'forget', 'fork', 'fortune', 'forum', 'forward',
|
||||||
|
'fossil', 'foster', 'found', 'fox', 'fragile', 'frame', 'frequent', 'fresh',
|
||||||
|
'friend', 'fringe', 'frog', 'front', 'frost', 'frown', 'frozen', 'fruit',
|
||||||
|
'fuel', 'fun', 'funny', 'furnace', 'fury', 'future', 'gadget', 'gain',
|
||||||
|
'galaxy', 'gallery', 'game', 'gap', 'garage', 'garbage', 'garden', 'garlic',
|
||||||
|
'garment', 'gas', 'gasp', 'gate', 'gather', 'gauge', 'gaze', 'general',
|
||||||
|
'genius', 'genre', 'gentle', 'genuine', 'gesture', 'ghost', 'giant', 'gift',
|
||||||
|
'giggle', 'ginger', 'giraffe', 'girl', 'give', 'glad', 'glance', 'glare',
|
||||||
|
'glass', 'glide', 'glimpse', 'globe', 'gloom', 'glory', 'glove', 'glow',
|
||||||
|
'glue', 'goat', 'goddess', 'gold', 'good', 'goose', 'gorilla', 'gospel',
|
||||||
|
'gossip', 'govern', 'gown', 'grab', 'grace', 'grain', 'grant', 'grape',
|
||||||
|
'grass', 'gravity', 'great', 'green', 'grid', 'grief', 'grit', 'grocery',
|
||||||
|
'group', 'grow', 'grunt', 'guard', 'guess', 'guide', 'guilt', 'guitar',
|
||||||
|
'gun', 'gym', 'habit', 'hair', 'half', 'hammer', 'hamster', 'hand',
|
||||||
|
'happy', 'harbor', 'hard', 'harsh', 'harvest', 'hat', 'have', 'hawk',
|
||||||
|
'hazard', 'head', 'health', 'hear', 'heart', 'heavy', 'hedgehog', 'height',
|
||||||
|
'hello', 'helmet', 'help', 'hen', 'hero', 'hidden', 'high', 'hill',
|
||||||
|
'hint', 'hip', 'hire', 'history', 'hobby', 'hockey', 'hold', 'hole',
|
||||||
|
'holiday', 'hollow', 'home', 'honey', 'hood', 'hope', 'horn', 'horror',
|
||||||
|
'horse', 'hospital', 'host', 'hotel', 'hour', 'hover', 'hub', 'huge',
|
||||||
|
'human', 'humble', 'humor', 'hundred', 'hungry', 'hunt', 'hurdle', 'hurry',
|
||||||
|
'hurt', 'husband', 'hybrid', 'ice', 'icon', 'idea', 'identify', 'idle',
|
||||||
|
'ignore', 'ill', 'illegal', 'illness', 'image', 'imitate', 'immense', 'immune',
|
||||||
|
'impact', 'impose', 'improve', 'impulse', 'inch', 'include', 'income', 'increase',
|
||||||
|
'index', 'indicate', 'indoor', 'industry', 'infant', 'inflict', 'inform', 'inhale',
|
||||||
|
'inherit', 'initial', 'inject', 'injury', 'inmate', 'inner', 'innocent', 'input',
|
||||||
|
'inquiry', 'insane', 'insect', 'inside', 'inspire', 'install', 'intact', 'interest',
|
||||||
|
'into', 'invest', 'invite', 'involve', 'iron', 'island', 'isolate', 'issue',
|
||||||
|
'item', 'ivory', 'jacket', 'jaguar', 'jar', 'jazz', 'jealous', 'jeans',
|
||||||
|
'jelly', 'jewel', 'job', 'join', 'joke', 'journey', 'joy', 'judge',
|
||||||
|
'juice', 'jump', 'jungle', 'junior', 'junk', 'just', 'kangaroo', 'keen',
|
||||||
|
'keep', 'ketchup', 'key', 'kick', 'kid', 'kidney', 'kind', 'kingdom',
|
||||||
|
'kiss', 'kit', 'kitchen', 'kite', 'kitten', 'kiwi', 'knee', 'knife',
|
||||||
|
'knock', 'know', 'lab', 'label', 'labor', 'ladder', 'lady', 'lake',
|
||||||
|
'lamp', 'language', 'laptop', 'large', 'later', 'latin', 'laugh', 'laundry',
|
||||||
|
'lava', 'law', 'lawn', 'lawsuit', 'layer', 'lazy', 'leader', 'leaf',
|
||||||
|
'learn', 'leave', 'lecture', 'left', 'leg', 'legal', 'legend', 'leisure',
|
||||||
|
'lemon', 'lend', 'length', 'lens', 'leopard', 'lesson', 'letter', 'level',
|
||||||
|
'liar', 'liberty', 'library', 'license', 'life', 'lift', 'light', 'like',
|
||||||
|
'limb', 'limit', 'link', 'lion', 'liquid', 'list', 'little', 'live',
|
||||||
|
'lizard', 'load', 'loan', 'lobster', 'local', 'lock', 'logic', 'lonely',
|
||||||
|
'long', 'loop', 'lottery', 'loud', 'lounge', 'love', 'loyal', 'lucky',
|
||||||
|
'luggage', 'lumber', 'lunar', 'lunch', 'luxury', 'lyrics', 'machine', 'mad',
|
||||||
|
'magic', 'magnet', 'maid', 'mail', 'main', 'major', 'make', 'mammal',
|
||||||
|
'man', 'manage', 'mandate', 'mango', 'mansion', 'manual', 'maple', 'marble',
|
||||||
|
'march', 'margin', 'marine', 'market', 'marriage', 'mask', 'mass', 'master',
|
||||||
|
'match', 'material', 'math', 'matrix', 'matter', 'maximum', 'maze', 'meadow',
|
||||||
|
'mean', 'measure', 'meat', 'mechanic', 'medal', 'media', 'melody', 'melt',
|
||||||
|
'member', 'memory', 'mention', 'menu', 'mercy', 'merge', 'merit', 'merry',
|
||||||
|
'mesh', 'message', 'metal', 'method', 'middle', 'midnight', 'milk', 'million',
|
||||||
|
'mimic', 'mind', 'minimum', 'minor', 'minute', 'miracle', 'mirror', 'misery',
|
||||||
|
'miss', 'mistake', 'mix', 'mixed', 'mixture', 'mobile', 'model', 'modify',
|
||||||
|
'mom', 'moment', 'monitor', 'monkey', 'monster', 'month', 'moon', 'moral',
|
||||||
|
'more', 'morning', 'mosquito', 'mother', 'motion', 'motor', 'mountain', 'mouse',
|
||||||
|
'move', 'movie', 'much', 'muffin', 'mule', 'multiply', 'muscle', 'museum',
|
||||||
|
'mushroom', 'music', 'must', 'mutual', 'myself', 'mystery', 'myth', 'naive',
|
||||||
|
'name', 'napkin', 'narrow', 'nasty', 'nation', 'nature', 'near', 'neck',
|
||||||
|
'need', 'negative', 'neglect', 'neither', 'nephew', 'nerve', 'nest', 'net',
|
||||||
|
'network', 'neutral', 'never', 'news', 'next', 'nice', 'night', 'noble',
|
||||||
|
'noise', 'nominee', 'noodle', 'normal', 'north', 'nose', 'notable', 'note',
|
||||||
|
'nothing', 'notice', 'novel', 'now', 'nuclear', 'number', 'nurse', 'nut',
|
||||||
|
'oak', 'obey', 'object', 'oblige', 'obscure', 'observe', 'obtain', 'obvious',
|
||||||
|
'occur', 'ocean', 'october', 'odor', 'off', 'offer', 'office', 'often',
|
||||||
|
'oil', 'okay', 'old', 'olive', 'olympic', 'omit', 'once', 'one',
|
||||||
|
'onion', 'online', 'only', 'open', 'opera', 'opinion', 'oppose', 'option',
|
||||||
|
'orange', 'orbit', 'orchard', 'order', 'ordinary', 'organ', 'orient', 'original',
|
||||||
|
'orphan', 'ostrich', 'other', 'outdoor', 'outer', 'output', 'outside', 'oval',
|
||||||
|
'oven', 'over', 'own', 'owner', 'oxygen', 'oyster', 'ozone', 'pact',
|
||||||
|
'paddle', 'page', 'pair', 'palace', 'palm', 'panda', 'panel', 'panic',
|
||||||
|
'panther', 'paper', 'parade', 'parent', 'park', 'parrot', 'party', 'pass',
|
||||||
|
'patch', 'path', 'patient', 'patrol', 'pattern', 'pause', 'pave', 'payment',
|
||||||
|
'peace', 'peanut', 'pear', 'peasant', 'pelican', 'pen', 'penalty', 'pencil',
|
||||||
|
'people', 'pepper', 'perfect', 'permit', 'person', 'pet', 'phone', 'photo',
|
||||||
|
'phrase', 'physical', 'piano', 'picnic', 'picture', 'piece', 'pig', 'pigeon',
|
||||||
|
'pill', 'pilot', 'pink', 'pioneer', 'pipe', 'pistol', 'pitch', 'pizza',
|
||||||
|
'place', 'planet', 'plastic', 'plate', 'play', 'please', 'pledge', 'pluck',
|
||||||
|
'plug', 'plunge', 'poem', 'poet', 'point', 'polar', 'pole', 'police',
|
||||||
|
'pond', 'pony', 'pool', 'popular', 'portion', 'position', 'possible', 'post',
|
||||||
|
'potato', 'pottery', 'poverty', 'powder', 'power', 'practice', 'praise', 'predict',
|
||||||
|
'prefer', 'prepare', 'present', 'pretty', 'prevent', 'price', 'pride', 'primary',
|
||||||
|
'print', 'priority', 'prison', 'private', 'prize', 'problem', 'process', 'produce',
|
||||||
|
'profit', 'program', 'project', 'promote', 'proof', 'property', 'prosper', 'protect',
|
||||||
|
'proud', 'provide', 'public', 'pudding', 'pull', 'pulp', 'pulse', 'pumpkin',
|
||||||
|
'punch', 'pupil', 'puppy', 'purchase', 'purity', 'purpose', 'purse', 'push',
|
||||||
|
'put', 'puzzle', 'pyramid', 'quality', 'quantum', 'quarter', 'question', 'quick',
|
||||||
|
'quit', 'quiz', 'quote', 'rabbit', 'raccoon', 'race', 'rack', 'radar',
|
||||||
|
'radio', 'rail', 'rain', 'raise', 'rally', 'ramp', 'ranch', 'random',
|
||||||
|
'range', 'rapid', 'rare', 'rate', 'rather', 'raven', 'raw', 'razor',
|
||||||
|
'ready', 'real', 'reason', 'rebel', 'rebuild', 'recall', 'receive', 'recipe',
|
||||||
|
'record', 'recycle', 'reduce', 'reflect', 'reform', 'refuse', 'region', 'regret',
|
||||||
|
'regular', 'reject', 'relax', 'release', 'relief', 'rely', 'remain', 'remember',
|
||||||
|
'remind', 'remove', 'render', 'renew', 'rent', 'reopen', 'repair', 'repeat',
|
||||||
|
'replace', 'report', 'require', 'rescue', 'resemble', 'resist', 'resource', 'response',
|
||||||
|
'result', 'retire', 'retreat', 'return', 'reunion', 'reveal', 'review', 'reward',
|
||||||
|
'rhythm', 'rib', 'ribbon', 'rice', 'rich', 'ride', 'ridge', 'rifle',
|
||||||
|
'right', 'rigid', 'ring', 'riot', 'rip', 'ripe', 'rise', 'risk',
|
||||||
|
'ritual', 'rival', 'river', 'road', 'roast', 'robot', 'robust', 'rocket',
|
||||||
|
'romance', 'roof', 'rookie', 'room', 'rose', 'rotate', 'rough', 'round',
|
||||||
|
'route', 'royal', 'rubber', 'rude', 'rug', 'rule', 'run', 'runway',
|
||||||
|
'rural', 'sad', 'saddle', 'sadness', 'safe', 'sail', 'salad', 'salmon',
|
||||||
|
'salon', 'salt', 'same', 'sample', 'sand', 'satisfy', 'satoshi', 'sauce',
|
||||||
|
'sausage', 'save', 'say', 'scale', 'scan', 'scare', 'scatter', 'scene',
|
||||||
|
'scheme', 'school', 'science', 'scissors', 'scorpion', 'scout', 'scrap', 'screen',
|
||||||
|
'script', 'scrub', 'sea', 'search', 'season', 'seat', 'second', 'secret',
|
||||||
|
'section', 'security', 'seed', 'seek', 'segment', 'select', 'sell', 'seminar',
|
||||||
|
'senior', 'sense', 'sentence', 'series', 'serious', 'service', 'session', 'settle',
|
||||||
|
'setup', 'seven', 'shadow', 'shaft', 'shallow', 'share', 'shed', 'shell',
|
||||||
|
'sheriff', 'shield', 'shift', 'shine', 'ship', 'shiver', 'shock', 'shoe',
|
||||||
|
'shoot', 'shop', 'short', 'shoulder', 'shove', 'shrimp', 'shrug', 'shuffle',
|
||||||
|
'shy', 'sibling', 'sick', 'side', 'siege', 'sight', 'sign', 'silent',
|
||||||
|
'silk', 'silly', 'silver', 'similar', 'simple', 'since', 'sing', 'siren',
|
||||||
|
'sister', 'situate', 'six', 'size', 'skate', 'sketch', 'ski', 'skill',
|
||||||
|
'skin', 'skirt', 'skull', 'slab', 'slam', 'sleep', 'slender', 'slice',
|
||||||
|
'slide', 'slight', 'slim', 'slogan', 'slot', 'slow', 'slush', 'small',
|
||||||
|
'smart', 'smile', 'smoke', 'smooth', 'snack', 'snake', 'snap', 'sniff',
|
||||||
|
'snow', 'soap', 'soccer', 'social', 'sock', 'soda', 'soft', 'solar',
|
||||||
|
'soldier', 'solid', 'solution', 'solve', 'someone', 'song', 'soon', 'sorry',
|
||||||
|
'sort', 'soul', 'sound', 'soup', 'source', 'south', 'space', 'spare',
|
||||||
|
'spatial', 'spawn', 'speak', 'special', 'speed', 'spell', 'spend', 'sphere',
|
||||||
|
'spice', 'spider', 'spike', 'spin', 'spirit', 'split', 'spoil', 'sponsor',
|
||||||
|
'spoon', 'sport', 'spot', 'spray', 'spread', 'spring', 'spy', 'square',
|
||||||
|
'squeeze', 'squirrel', 'stable', 'stadium', 'staff', 'stage', 'stair', 'stake',
|
||||||
|
'stale', 'stamp', 'stand', 'start', 'state', 'stay', 'steak', 'steel',
|
||||||
|
'stem', 'step', 'stereo', 'stick', 'still', 'sting', 'stock', 'stomach',
|
||||||
|
'stone', 'stool', 'story', 'stove', 'strategy', 'street', 'strike', 'strong',
|
||||||
|
'struggle', 'student', 'stuff', 'stumble', 'style', 'subject', 'submit', 'subway',
|
||||||
|
'success', 'such', 'sudden', 'suffer', 'sugar', 'suggest', 'suit', 'summer',
|
||||||
|
'sun', 'sunny', 'sunset', 'super', 'supply', 'support', 'sure', 'surface',
|
||||||
|
'surge', 'surprise', 'surround', 'survey', 'suspect', 'sustain', 'swallow', 'swamp',
|
||||||
|
'swap', 'swarm', 'swear', 'sweet', 'swift', 'swim', 'swing', 'switch',
|
||||||
|
'sword', 'symbol', 'symptom', 'syrup', 'system', 'table', 'tackle', 'tag',
|
||||||
|
'tail', 'talent', 'talk', 'tank', 'tape', 'target', 'task', 'taste',
|
||||||
|
'tattoo', 'taxi', 'teach', 'team', 'tell', 'ten', 'tenant', 'tennis',
|
||||||
|
'tent', 'term', 'test', 'text', 'thank', 'that', 'theme', 'then',
|
||||||
|
'theory', 'there', 'they', 'thing', 'this', 'thought', 'three', 'thrive',
|
||||||
|
'throw', 'thumb', 'thunder', 'ticket', 'tide', 'tiger', 'tilt', 'timber',
|
||||||
|
'time', 'tiny', 'tip', 'tired', 'tissue', 'title', 'toast', 'tobacco',
|
||||||
|
'today', 'toddler', 'toe', 'together', 'toilet', 'token', 'tomato', 'tomorrow',
|
||||||
|
'tone', 'tongue', 'tonight', 'tool', 'tooth', 'top', 'topic', 'topple',
|
||||||
|
'torch', 'tornado', 'tortoise', 'toss', 'total', 'tourist', 'toward', 'tower',
|
||||||
|
'town', 'toy', 'track', 'trade', 'traffic', 'tragic', 'train', 'transfer',
|
||||||
|
'trap', 'trash', 'travel', 'tray', 'treat', 'tree', 'trend', 'trial',
|
||||||
|
'tribe', 'trick', 'trigger', 'trim', 'trip', 'trophy', 'trouble', 'truck',
|
||||||
|
'true', 'truly', 'trumpet', 'trust', 'truth', 'try', 'tube', 'tuition',
|
||||||
|
'tumble', 'tuna', 'tunnel', 'turkey', 'turn', 'turtle', 'twelve', 'twenty',
|
||||||
|
'twice', 'twin', 'twist', 'two', 'type', 'typical', 'ugly', 'umbrella',
|
||||||
|
'unable', 'unaware', 'uncle', 'uncover', 'under', 'undo', 'unfair', 'unfold',
|
||||||
|
'unhappy', 'uniform', 'unique', 'unit', 'universe', 'unknown', 'unlock', 'until',
|
||||||
|
'unusual', 'unveil', 'update', 'upgrade', 'uphold', 'upon', 'upper', 'upset',
|
||||||
|
'urban', 'urge', 'usage', 'use', 'used', 'useful', 'useless', 'usual',
|
||||||
|
'utility', 'vacant', 'vacuum', 'vague', 'valid', 'valley', 'valve', 'van',
|
||||||
|
'vanish', 'vapor', 'various', 'vast', 'vault', 'vehicle', 'velvet', 'vendor',
|
||||||
|
'venture', 'venue', 'verb', 'verify', 'version', 'very', 'vessel', 'veteran',
|
||||||
|
'viable', 'vibrant', 'vicious', 'victory', 'video', 'view', 'village', 'vintage',
|
||||||
|
'violin', 'virtual', 'virus', 'visa', 'visit', 'visual', 'vital', 'vivid',
|
||||||
|
'vocal', 'voice', 'void', 'volcano', 'volume', 'vote', 'voyage', 'wage',
|
||||||
|
'wagon', 'wait', 'walk', 'wall', 'walnut', 'want', 'warfare', 'warm',
|
||||||
|
'warrior', 'wash', 'wasp', 'waste', 'water', 'wave', 'way', 'wealth',
|
||||||
|
'weapon', 'weary', 'weather', 'weave', 'web', 'wedding', 'weekend', 'weird',
|
||||||
|
'welcome', 'west', 'wet', 'whale', 'what', 'wheat', 'wheel', 'when',
|
||||||
|
'where', 'whip', 'whisper', 'wide', 'width', 'wife', 'wild', 'will',
|
||||||
|
'win', 'window', 'wine', 'wing', 'wink', 'winner', 'winter', 'wire',
|
||||||
|
'wisdom', 'wise', 'wish', 'witness', 'wolf', 'woman', 'wonder', 'wood',
|
||||||
|
'wool', 'word', 'work', 'world', 'worry', 'worth', 'wrap', 'wreck',
|
||||||
|
'wrestle', 'wrist', 'write', 'wrong', 'yard', 'year', 'yellow', 'you',
|
||||||
|
'young', 'youth', 'zebra', 'zero', 'zone', 'zoo',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert UUID to BIP32 word list.
|
||||||
|
* UUID is converted to bytes, then to BIP32 path, then to words.
|
||||||
|
*/
|
||||||
|
export function uuidToBip32Words(uuid: string): string[] {
|
||||||
|
const uuidBytes = hexToBytes(uuid.replace(/-/g, ''));
|
||||||
|
const words: string[] = [];
|
||||||
|
for (let i = 0; i < uuidBytes.length; i += 2) {
|
||||||
|
const index = (uuidBytes[i]! << 8) | (uuidBytes[i + 1] ?? 0);
|
||||||
|
words.push(BIP32_WORDLIST[index % BIP32_WORDLIST.length]!);
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert BIP32 word list back to UUID.
|
||||||
|
*/
|
||||||
|
export function bip32WordsToUuid(words: string[]): string | null {
|
||||||
|
try {
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (const word of words) {
|
||||||
|
const index = BIP32_WORDLIST.indexOf(word.toLowerCase());
|
||||||
|
if (index === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
indices.push(index);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(indices.length * 2);
|
||||||
|
for (let i = 0; i < indices.length; i++) {
|
||||||
|
bytes[i * 2] = (indices[i]! >> 8) & 0xff;
|
||||||
|
bytes[i * 2 + 1] = indices[i]! & 0xff;
|
||||||
|
}
|
||||||
|
const hex = bytesToHex(bytes);
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random UUID v4.
|
||||||
|
*/
|
||||||
|
export function generateUuid(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
93
userwallet/src/utils/cache.ts
Normal file
93
userwallet/src/utils/cache.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Persistent cache for seen hashes to avoid rescanning.
|
||||||
|
*/
|
||||||
|
export class HashCache {
|
||||||
|
private cache: Set<string> = new Set();
|
||||||
|
private storageKey: string;
|
||||||
|
|
||||||
|
constructor(storageKey: string = 'userwallet_hash_cache') {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
this.loadFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hash has been seen.
|
||||||
|
*/
|
||||||
|
hasSeen(hash: string): boolean {
|
||||||
|
return this.cache.has(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a hash as seen.
|
||||||
|
*/
|
||||||
|
markSeen(hash: string): void {
|
||||||
|
this.cache.add(hash);
|
||||||
|
this.saveToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark multiple hashes as seen.
|
||||||
|
*/
|
||||||
|
markSeenBatch(hashes: string[]): void {
|
||||||
|
for (const hash of hashes) {
|
||||||
|
this.cache.add(hash);
|
||||||
|
}
|
||||||
|
this.saveToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
localStorage.removeItem(this.storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache size.
|
||||||
|
*/
|
||||||
|
size(): number {
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cache from localStorage.
|
||||||
|
*/
|
||||||
|
private loadFromStorage(): void {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.storageKey);
|
||||||
|
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);
|
||||||
|
if (hashes.length > 10000) {
|
||||||
|
this.cache = new Set(hashes.slice(-10000));
|
||||||
|
this.saveToStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
userwallet/src/utils/canonical.ts
Normal file
49
userwallet/src/utils/canonical.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Canonical JSON serialization.
|
||||||
|
* Order keys, normalize encoding, handle arrays consistently.
|
||||||
|
*/
|
||||||
|
export function canonicalizeJson(obj: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify(obj, Object.keys(obj).sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate canonical hash of a message.
|
||||||
|
* Excludes: hash, signatures, cles_de_chiffrement.
|
||||||
|
*/
|
||||||
|
export function calculateCanonicalHash(
|
||||||
|
message: Record<string, unknown>,
|
||||||
|
algo: string = 'sha256',
|
||||||
|
): string {
|
||||||
|
const { hash, signatures, cles_de_chiffrement, ...canonical } = message;
|
||||||
|
const canonicalJson = canonicalizeJson(canonical);
|
||||||
|
return hashString(canonicalJson, algo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a string with specified algorithm.
|
||||||
|
*/
|
||||||
|
export function hashString(input: string, algo: string): string {
|
||||||
|
if (algo === 'sha256') {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
return crypto.subtle.digest('SHA-256', data).then((hashBuffer) => {
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}) as unknown as string;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported hash algorithm: ${algo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async version of hashString.
|
||||||
|
*/
|
||||||
|
export async function hashStringAsync(input: string, algo: string): Promise<string> {
|
||||||
|
if (algo === 'sha256') {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported hash algorithm: ${algo}`);
|
||||||
|
}
|
||||||
74
userwallet/src/utils/crypto.ts
Normal file
74
userwallet/src/utils/crypto.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
getPublicKey,
|
||||||
|
sign,
|
||||||
|
Signature,
|
||||||
|
utils as secpUtils,
|
||||||
|
verify,
|
||||||
|
} from '@noble/secp256k1';
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
export interface KeyPair {
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new secp256k1 key pair for authentication.
|
||||||
|
* The private key is kept secret, the public key is used for verification.
|
||||||
|
*/
|
||||||
|
export function generateKeyPair(): KeyPair {
|
||||||
|
const privateKey = secpUtils.randomPrivateKey();
|
||||||
|
const publicKey = getPublicKey(privateKey, true);
|
||||||
|
return {
|
||||||
|
privateKey: bytesToHex(privateKey),
|
||||||
|
publicKey: bytesToHex(publicKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the secp256k1 public key (compressed, hex) from a raw hex private key.
|
||||||
|
* Input must be 64 hex characters (32 bytes). Throws if invalid.
|
||||||
|
*/
|
||||||
|
export function publicKeyFromPrivateKey(privateKeyHex: string): string {
|
||||||
|
const key = hexToBytes(privateKeyHex);
|
||||||
|
const pub = getPublicKey(key, true);
|
||||||
|
return bytesToHex(pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs a message with a private key using secp256k1.
|
||||||
|
* The message is hashed with SHA-256 before signing.
|
||||||
|
*/
|
||||||
|
export function signMessage(message: string, privateKeyHex: string): string {
|
||||||
|
const messageHash = sha256(message);
|
||||||
|
const privateKey = hexToBytes(privateKeyHex);
|
||||||
|
const sig = sign(messageHash, privateKey);
|
||||||
|
return bytesToHex(sig.toCompactRawBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a signature against a message and public key.
|
||||||
|
* Returns false if verification fails or if any error occurs.
|
||||||
|
*/
|
||||||
|
export function verifySignature(
|
||||||
|
message: string,
|
||||||
|
signatureHex: string,
|
||||||
|
publicKeyHex: string,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const messageHash = sha256(message);
|
||||||
|
const sig = Signature.fromCompact(hexToBytes(signatureHex));
|
||||||
|
const pub = hexToBytes(publicKeyHex);
|
||||||
|
return verify(sig, messageHash, pub);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signature verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateChallenge(): string {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
return `auth-challenge-${timestamp}-${bytesToHex(random)}`;
|
||||||
|
}
|
||||||
179
userwallet/src/utils/encryption.ts
Normal file
179
userwallet/src/utils/encryption.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { getPublicKey, getSharedSecret } from '@noble/secp256k1';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data with AES-GCM using a shared secret derived from ECDH.
|
||||||
|
*/
|
||||||
|
export async function encryptWithECDH(
|
||||||
|
data: string,
|
||||||
|
recipientPublicKey: string,
|
||||||
|
senderPrivateKey: string,
|
||||||
|
): Promise<{
|
||||||
|
encrypted: string;
|
||||||
|
iv: string;
|
||||||
|
publicKey: string;
|
||||||
|
}> {
|
||||||
|
const recipientPubKey = hexToBytes(recipientPublicKey);
|
||||||
|
const senderPrivKey = hexToBytes(senderPrivateKey);
|
||||||
|
|
||||||
|
const sharedSecret = getSharedSecret(senderPrivKey, recipientPubKey, true);
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
sharedSecret.slice(0, 32) as BufferSource,
|
||||||
|
{ name: 'HKDF' },
|
||||||
|
false,
|
||||||
|
['deriveBits', 'deriveKey'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const derivedKey = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: new Uint8Array(32),
|
||||||
|
info: new Uint8Array(0),
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const dataBytes = encoder.encode(data);
|
||||||
|
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
derivedKey,
|
||||||
|
dataBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted: bytesToHex(new Uint8Array(encrypted)),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
publicKey: bytesToHex(getPublicKey(senderPrivKey, true)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data with AES-GCM using a shared secret derived from ECDH.
|
||||||
|
*/
|
||||||
|
export async function decryptWithECDH(
|
||||||
|
encrypted: string,
|
||||||
|
iv: string,
|
||||||
|
senderPublicKey: string,
|
||||||
|
recipientPrivateKey: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const senderPubKey = hexToBytes(senderPublicKey);
|
||||||
|
const recipientPrivKey = hexToBytes(recipientPrivateKey);
|
||||||
|
|
||||||
|
const sharedSecret = getSharedSecret(recipientPrivKey, senderPubKey, true);
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
sharedSecret.slice(0, 32) as BufferSource,
|
||||||
|
{ name: 'HKDF' },
|
||||||
|
false,
|
||||||
|
['deriveBits', 'deriveKey'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const derivedKey = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: new Uint8Array(32),
|
||||||
|
info: new Uint8Array(0),
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const encryptedBytes = hexToBytes(encrypted);
|
||||||
|
const ivBytes = hexToBytes(iv);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: ivBytes as BufferSource,
|
||||||
|
},
|
||||||
|
derivedKey,
|
||||||
|
encryptedBytes as BufferSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data for "publish to all" scenario.
|
||||||
|
* Uses a symmetric key that can be shared via ECDH with multiple recipients.
|
||||||
|
*/
|
||||||
|
export async function encryptForAll(
|
||||||
|
data: string,
|
||||||
|
encryptionKey: Uint8Array,
|
||||||
|
): Promise<{
|
||||||
|
encrypted: string;
|
||||||
|
iv: string;
|
||||||
|
}> {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encryptionKey as BufferSource,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const dataBytes = encoder.encode(data);
|
||||||
|
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: iv as BufferSource,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
dataBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted: bytesToHex(new Uint8Array(encrypted)),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data encrypted with encryptForAll.
|
||||||
|
*/
|
||||||
|
export async function decryptForAll(
|
||||||
|
encrypted: string,
|
||||||
|
iv: string,
|
||||||
|
encryptionKey: Uint8Array,
|
||||||
|
): Promise<string> {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encryptionKey as BufferSource,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const encryptedBytes = hexToBytes(encrypted);
|
||||||
|
const ivBytes = hexToBytes(iv);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: ivBytes as BufferSource,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encryptedBytes as BufferSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(decrypted);
|
||||||
|
}
|
||||||
81
userwallet/src/utils/identity.ts
Normal file
81
userwallet/src/utils/identity.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { generateKeyPair, publicKeyFromPrivateKey } from './crypto';
|
||||||
|
import { generateUuid } from './bip32';
|
||||||
|
import type { LocalIdentity } from '../types/identity';
|
||||||
|
|
||||||
|
const STORAGE_KEY_IDENTITY = 'userwallet_identity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored local identity.
|
||||||
|
*/
|
||||||
|
export function getStoredIdentity(): LocalIdentity | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_IDENTITY);
|
||||||
|
if (stored === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(stored) as LocalIdentity;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading stored identity:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store local identity.
|
||||||
|
*/
|
||||||
|
export function storeIdentity(identity: LocalIdentity): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY_IDENTITY, JSON.stringify(identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new local identity.
|
||||||
|
*/
|
||||||
|
export function createIdentity(name?: string): LocalIdentity {
|
||||||
|
const keyPair = generateKeyPair();
|
||||||
|
const identity: LocalIdentity = {
|
||||||
|
uuid: generateUuid(),
|
||||||
|
privateKey: keyPair.privateKey,
|
||||||
|
publicKey: keyPair.publicKey,
|
||||||
|
name,
|
||||||
|
t0_anniversaire: Date.now(),
|
||||||
|
version: '1.0',
|
||||||
|
};
|
||||||
|
storeIdentity(identity);
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function importIdentity(
|
||||||
|
seedOrPrivateKey: string,
|
||||||
|
name?: string,
|
||||||
|
): LocalIdentity | null {
|
||||||
|
const raw = seedOrPrivateKey.trim().toLowerCase().replace(/^0x/, '');
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(raw)) {
|
||||||
|
console.error(
|
||||||
|
'Import identity: expected 64 hex chars (32-byte private key). Mnemonic/seed not supported.',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const publicKey = publicKeyFromPrivateKey(raw);
|
||||||
|
const identity: LocalIdentity = {
|
||||||
|
uuid: generateUuid(),
|
||||||
|
privateKey: raw,
|
||||||
|
publicKey,
|
||||||
|
name,
|
||||||
|
t0_anniversaire: Date.now(),
|
||||||
|
version: '1.0',
|
||||||
|
};
|
||||||
|
storeIdentity(identity);
|
||||||
|
return identity;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing identity:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
userwallet/src/utils/iframe.ts
Normal file
48
userwallet/src/utils/iframe.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import type {
|
||||||
|
IframeMessage,
|
||||||
|
AuthResponseMessage,
|
||||||
|
ServiceToggleMessage,
|
||||||
|
ServiceStatusMessage,
|
||||||
|
} from '../types/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message to the parent window if running in an iframe.
|
||||||
|
* Uses postMessage API for cross-origin communication.
|
||||||
|
*/
|
||||||
|
export function sendMessageToParent(message: IframeMessage): void {
|
||||||
|
if (window.parent === window) {
|
||||||
|
console.warn('Not in iframe context, message not sent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.parent.postMessage(message, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendAuthResponse(response: AuthResponseMessage['payload']): void {
|
||||||
|
sendMessageToParent({
|
||||||
|
type: 'auth-response',
|
||||||
|
payload: response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendServiceToggle(serviceId: string, enabled: boolean): void {
|
||||||
|
sendMessageToParent({
|
||||||
|
type: 'service-toggle',
|
||||||
|
payload: {
|
||||||
|
serviceId,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
} as ServiceToggleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendServiceStatus(services: ServiceStatusMessage['payload']['services']): void {
|
||||||
|
sendMessageToParent({
|
||||||
|
type: 'service-status',
|
||||||
|
payload: {
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
} as ServiceStatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInIframe(): boolean {
|
||||||
|
return window.parent !== window;
|
||||||
|
}
|
||||||
82
userwallet/src/utils/iframeChannel.ts
Normal file
82
userwallet/src/utils/iframeChannel.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import type { ServiceConfig } from '../types/auth';
|
||||||
|
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';
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequestMessage extends ChannelMessage {
|
||||||
|
type: 'auth-request';
|
||||||
|
payload: {
|
||||||
|
serviceId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponseMessage extends ChannelMessage {
|
||||||
|
type: 'auth-response';
|
||||||
|
payload: {
|
||||||
|
signature: string;
|
||||||
|
publicKey: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginProofMessage extends ChannelMessage {
|
||||||
|
type: 'login-proof';
|
||||||
|
payload: LoginProof;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatusMessage extends ChannelMessage {
|
||||||
|
type: 'service-status';
|
||||||
|
payload: {
|
||||||
|
services: ServiceConfig[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage extends ChannelMessage {
|
||||||
|
type: 'error';
|
||||||
|
payload: {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to parent window (Channel Messages).
|
||||||
|
*/
|
||||||
|
export function sendToChannel(message: ChannelMessage): void {
|
||||||
|
if (window.parent === window) {
|
||||||
|
console.warn('Not in iframe context, message not sent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.parent.postMessage(message, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for messages from parent (Channel Messages).
|
||||||
|
*/
|
||||||
|
export function listenToChannel(
|
||||||
|
handler: (message: ChannelMessage) => void,
|
||||||
|
): () => void {
|
||||||
|
const messageHandler = (event: MessageEvent<ChannelMessage>): void => {
|
||||||
|
if (event.data.type !== undefined) {
|
||||||
|
handler(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', messageHandler);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in iframe.
|
||||||
|
*/
|
||||||
|
export function isInIframe(): boolean {
|
||||||
|
return window.parent !== window;
|
||||||
|
}
|
||||||
87
userwallet/src/utils/pairing.ts
Normal file
87
userwallet/src/utils/pairing.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { generateUuid, uuidToBip32Words, bip32WordsToUuid } from './bip32';
|
||||||
|
import type { PairConfig } from '../types/identity';
|
||||||
|
|
||||||
|
const STORAGE_KEY_PAIRS = 'userwallet_pairs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored pair configurations.
|
||||||
|
*/
|
||||||
|
export function getStoredPairs(): PairConfig[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_PAIRS);
|
||||||
|
if (stored === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return JSON.parse(stored) as PairConfig[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading stored pairs:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store pair configurations.
|
||||||
|
*/
|
||||||
|
export function storePairs(pairs: PairConfig[]): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY_PAIRS, JSON.stringify(pairs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new local pair.
|
||||||
|
*/
|
||||||
|
export function createLocalPair(membresParentsUuid: string[]): {
|
||||||
|
pair: PairConfig;
|
||||||
|
words: string[];
|
||||||
|
} {
|
||||||
|
const uuid = generateUuid();
|
||||||
|
const words = uuidToBip32Words(uuid);
|
||||||
|
const pair: PairConfig = {
|
||||||
|
uuid,
|
||||||
|
membres_parents_uuid: membresParentsUuid,
|
||||||
|
is_local: true,
|
||||||
|
can_sign: true,
|
||||||
|
};
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
pairs.push(pair);
|
||||||
|
storePairs(pairs);
|
||||||
|
return { pair, words };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a remote pair from BIP32 words.
|
||||||
|
*/
|
||||||
|
export function addRemotePairFromWords(
|
||||||
|
words: string[],
|
||||||
|
membresParentsUuid: string[],
|
||||||
|
): PairConfig | null {
|
||||||
|
const uuid = bip32WordsToUuid(words);
|
||||||
|
if (uuid === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pair: PairConfig = {
|
||||||
|
uuid,
|
||||||
|
membres_parents_uuid: membresParentsUuid,
|
||||||
|
is_local: false,
|
||||||
|
can_sign: false,
|
||||||
|
};
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
pairs.push(pair);
|
||||||
|
storePairs(pairs);
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pairing is satisfied (at least one pair available).
|
||||||
|
*/
|
||||||
|
export function isPairingSatisfied(): boolean {
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
return pairs.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pairs for a specific member.
|
||||||
|
*/
|
||||||
|
export function getPairsForMember(membreUuid: string): PairConfig[] {
|
||||||
|
const pairs = getStoredPairs();
|
||||||
|
return pairs.filter((p) => p.membres_parents_uuid.includes(membreUuid));
|
||||||
|
}
|
||||||
145
userwallet/src/utils/relay.ts
Normal file
145
userwallet/src/utils/relay.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import type { RelayConfig } from '../types/identity';
|
||||||
|
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored relay configurations.
|
||||||
|
*/
|
||||||
|
export function getStoredRelays(): RelayConfig[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('userwallet_relays');
|
||||||
|
if (stored === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return JSON.parse(stored) as RelayConfig[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading stored relays:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store relay configurations.
|
||||||
|
*/
|
||||||
|
export function storeRelays(relays: RelayConfig[]): void {
|
||||||
|
localStorage.setItem('userwallet_relays', JSON.stringify(relays));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test relay connectivity.
|
||||||
|
*/
|
||||||
|
export async function testRelay(endpoint: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET encrypted messages from relay.
|
||||||
|
* Throws on fetch failure or non-ok response.
|
||||||
|
*/
|
||||||
|
export async function getMessagesChiffres(
|
||||||
|
relay: string,
|
||||||
|
windowStart: number,
|
||||||
|
windowEnd: number,
|
||||||
|
serviceUuid?: string,
|
||||||
|
): Promise<MsgChiffre[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: windowStart.toString(),
|
||||||
|
end: windowEnd.toString(),
|
||||||
|
});
|
||||||
|
if (serviceUuid !== undefined) {
|
||||||
|
params.append('service', serviceUuid);
|
||||||
|
}
|
||||||
|
const response = await fetch(`${relay}/messages?${params.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay GET /messages failed: ${response.status} ${response.statusText} (${relay})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as MsgChiffre[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET signatures for a message hash.
|
||||||
|
* Throws on fetch failure or non-ok response.
|
||||||
|
*/
|
||||||
|
export async function getSignatures(relay: string, hash: string): Promise<MsgSignature[]> {
|
||||||
|
const response = await fetch(`${relay}/signatures/${hash}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay GET /signatures/${hash} failed: ${response.status} ${response.statusText} (${relay})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as MsgSignature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET decryption keys for a message hash.
|
||||||
|
* Throws on fetch failure or non-ok response.
|
||||||
|
*/
|
||||||
|
export async function getKeys(relay: string, hash: string): Promise<MsgCle[]> {
|
||||||
|
const response = await fetch(`${relay}/keys/${hash}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay GET /keys/${hash} failed: ${response.status} ${response.statusText} (${relay})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as MsgCle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST encrypted message to relay.
|
||||||
|
* Throws on fetch failure or non-ok response.
|
||||||
|
*/
|
||||||
|
export async function postMessageChiffre(relay: string, message: MsgChiffre): Promise<void> {
|
||||||
|
const response = await fetch(`${relay}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(message),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay POST /messages failed: ${response.status} ${response.statusText} (${relay})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST signature to relay.
|
||||||
|
* Throws on fetch failure or non-ok response.
|
||||||
|
*/
|
||||||
|
export async function postSignature(relay: string, signature: MsgSignature): Promise<void> {
|
||||||
|
const response = await fetch(`${relay}/signatures`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(signature),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay POST /signatures failed: ${response.status} ${response.statusText} (${relay})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST decryption key to relay.
|
||||||
|
* Throws on fetch failure or non-ok response.
|
||||||
|
*/
|
||||||
|
export async function postKey(relay: string, key: MsgCle): Promise<void> {
|
||||||
|
const response = await fetch(`${relay}/keys`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(key),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Relay POST /keys failed: ${response.status} ${response.statusText} (${relay})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
userwallet/src/utils/storage.ts
Normal file
50
userwallet/src/utils/storage.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import type { KeyPair } from '../utils/crypto';
|
||||||
|
import type { ServiceConfig } from '../types/auth';
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
KEY_PAIR: 'userwallet_keypair',
|
||||||
|
SERVICES: 'userwallet_services',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getStoredKeyPair(): KeyPair | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.KEY_PAIR);
|
||||||
|
if (stored === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(stored) as KeyPair;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading stored key pair:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeKeyPair(keyPair: KeyPair): void {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.KEY_PAIR, JSON.stringify(keyPair));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredServices(): ServiceConfig[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.SERVICES);
|
||||||
|
if (stored === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return JSON.parse(stored) as ServiceConfig[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading stored services:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeServices(services: ServiceConfig[]): void {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.SERVICES, JSON.stringify(services));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateServiceStatus(serviceId: string, enabled: boolean): void {
|
||||||
|
const services = getStoredServices();
|
||||||
|
const service = services.find((s) => s.id === serviceId);
|
||||||
|
if (service !== undefined) {
|
||||||
|
service.enabled = enabled;
|
||||||
|
storeServices(services);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
userwallet/src/utils/verification.ts
Normal file
119
userwallet/src/utils/verification.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { verifySignature } from './crypto';
|
||||||
|
import { hashStringAsync } from './canonical';
|
||||||
|
import type { MessageBase, Signature } from '../types/message';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a message's canonical hash.
|
||||||
|
*/
|
||||||
|
export async function verifyMessageHash(
|
||||||
|
message: MessageBase,
|
||||||
|
algo: string = 'sha256',
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { hash, signatures, cles_de_chiffrement, ...canonical } = message;
|
||||||
|
const calculatedHash = await hashStringAsync(
|
||||||
|
JSON.stringify(canonical),
|
||||||
|
algo,
|
||||||
|
);
|
||||||
|
return hash?.hash_value === calculatedHash;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all signatures of a message.
|
||||||
|
*/
|
||||||
|
export function verifyMessageSignatures(
|
||||||
|
message: MessageBase,
|
||||||
|
signatures: Signature[],
|
||||||
|
): {
|
||||||
|
valid: Signature[];
|
||||||
|
invalid: Signature[];
|
||||||
|
} {
|
||||||
|
const valid: Signature[] = [];
|
||||||
|
const invalid: Signature[] = [];
|
||||||
|
|
||||||
|
for (const sig of signatures) {
|
||||||
|
if (sig.hash !== message.hash?.hash_value) {
|
||||||
|
invalid.push(sig);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageToVerify = `${sig.hash}-${sig.nonce}`;
|
||||||
|
const isValid = verifySignature(
|
||||||
|
messageToVerify,
|
||||||
|
sig.signature,
|
||||||
|
sig.cle_publique,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
valid.push(sig);
|
||||||
|
} else {
|
||||||
|
invalid.push(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid, invalid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a nonce has been used before (anti-replay).
|
||||||
|
*/
|
||||||
|
export class NonceCache {
|
||||||
|
private cache: Map<string, number> = new Map();
|
||||||
|
private ttl: number;
|
||||||
|
|
||||||
|
constructor(ttlMs: number = 3600000) {
|
||||||
|
this.ttl = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a nonce is valid (not seen before in TTL window).
|
||||||
|
*/
|
||||||
|
isValid(nonce: string, timestamp: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.cache.get(nonce);
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
if (now - cached < this.ttl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.cache.delete(nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(nonce, timestamp);
|
||||||
|
this.cleanup(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries.
|
||||||
|
*/
|
||||||
|
private cleanup(now: number): void {
|
||||||
|
for (const [nonce, timestamp] of this.cache.entries()) {
|
||||||
|
if (now - timestamp >= this.ttl) {
|
||||||
|
this.cache.delete(nonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all entries.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify timestamp is within acceptable window.
|
||||||
|
*/
|
||||||
|
export function verifyTimestamp(
|
||||||
|
timestamp: number,
|
||||||
|
windowMs: number = 300000,
|
||||||
|
): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = Math.abs(now - timestamp);
|
||||||
|
return diff <= windowMs;
|
||||||
|
}
|
||||||
20
userwallet/start.sh
Executable file
20
userwallet/start.sh
Executable file
@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and serve UserWallet on port 3018 (production).
|
||||||
|
# Usage: ./start.sh
|
||||||
|
# Ensure a web service responds for Certbot / proxy (userwallet.certificator.4nkweb.com -> .105:3018).
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
export PORT=${PORT:-3018}
|
||||||
|
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
echo "Installation des dépendances..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build du frontend UserWallet..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "Démarrage du serveur sur le port $PORT..."
|
||||||
|
exec npx vite preview --port "$PORT" --host 0.0.0.0
|
||||||
22
userwallet/tsconfig.json
Normal file
22
userwallet/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
userwallet/tsconfig.node.json
Normal file
11
userwallet/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
21
userwallet/userwallet.service
Normal file
21
userwallet/userwallet.service
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=UserWallet frontend (secp256k1 login)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ncantu
|
||||||
|
WorkingDirectory=/home/ncantu/Bureau/code/bitcoin/userwallet
|
||||||
|
Environment=PORT=3018
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/bin/bash /home/ncantu/Bureau/code/bitcoin/userwallet/start.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
19
userwallet/vite.config.ts
Normal file
19
userwallet/vite.config.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3018,
|
||||||
|
strictPort: false,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 3018,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: ['userwallet.certificator.4nkweb.com'],
|
||||||
|
},
|
||||||
|
});
|
||||||
58647
utxo_list.txt
58647
utxo_list.txt
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
|||||||
2026-01-25T16:07:02.209Z
|
2026-01-25T23:58:25.499Z;9248
|
||||||
Loading…
x
Reference in New Issue
Block a user