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:
ncantu 2026-01-26 01:59:46 +01:00
parent cc054c8904
commit cad73cb265
90 changed files with 57820 additions and 21275 deletions

View File

@ -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
View File

@ -0,0 +1,6 @@
node_modules
dist
data
*.log
.env
.env.local

76
api-relay/README.md Normal file
View 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.).

View 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

File diff suppressed because it is too large Load Diff

30
api-relay/package.json Normal file
View 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
View 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);
});

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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}`);
}
}
}

View 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));
}
}

View 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
View 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"]
}

View File

@ -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"

View 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

View 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 denvironnement 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 lenv.
## Correctifs
### 1. features/api-relay.md
- Variables denvironnement : défaut `PORT` 8080 → 3019.
- Exemples (PORT, PEER_RELAYS, curl) : 8080 → 3019.
- Structure du projet : suppression de `config.ts` dans larbre ; 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 danalyse
- 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.

View 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)

View 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 à limport didentité, 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 derreur, 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 linterface 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 lentrée est 64 caractères hexadécimaux (32 octets), après trim et suppression dun 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 limport dune 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 cidessus.
## 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 danalyse
- **Relais** : Poster un message sur un relais avec pairs configurés ; vérifier quil 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 sassurer quaucune régression sur la résolution du graphe.
- **relay.ts** : En cas de relais injoignable ou réponse non ok, vérifier que lerreur 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.

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
2026-01-25T16:04:04.413Z;8701;0000000ad30983fec618eebdfb5da673175f5b54c63ea3ef7d87a1fffb18e33a 2026-01-25T23:53:35.210Z;9245;000000057a9ba10877ec7e33c55ab124354dd4cd693992d115068dabdf868264

19
restart-userwallet.sh Executable file
View 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é ==="

View 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);
}

View 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
View 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);

View File

@ -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>

View File

@ -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>
`;
}
} }
} }
} }

View File

@ -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>

View File

@ -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é

View File

@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

171
userwallet/docs/storage.md Normal file
View 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 à larrêt (SIGINT/SIGTERM) et périodique (configurable via `SAVE_INTERVAL_SECONDS`).
- **Front (userwallet)** : LocalStorage (`userwallet_identity`, `userwallet_relays`, `userwallet_pairs`, `userwallet_hash_cache` ; legacy : `userwallet_keypair`, `userwallet_services`). Graphe contractuel en mémoire uniquement (`GraphResolver`).
## 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** : À larrê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
View 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, 30153017.
### 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 |

View 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'] }],
},
},
);

View 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.)

View 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

View 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 lhô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 dun 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 dun service qui répond sur **192.168.1.105:3018**. Sur lhôte bitcoin (.105) :
- Lancer `./userwallet/start.sh` (build + `vite preview` sur 3018), **ou**
- Installer lunité 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 danalyse
- Depuis lexté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
View 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

File diff suppressed because it is too large Load Diff

35
userwallet/package.json Normal file
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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(),
};
}

View 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,
};
}

View 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,
};
}

View 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
View 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
View 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>,
);

View 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,
};
}
}

View 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);
}
}

View 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);
}
}
}

View 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[];
};
}

View 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[];
}

View 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';
}

View 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;
}

View 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();
}

View 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();
}
}
}

View 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}`);
}

View 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)}`;
}

View 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);
}

View 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;
}
}

View 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;
}

View 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;
}

View 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));
}

View 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})`,
);
}
}

View 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);
}
}

View 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
View 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
View 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" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View 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
View 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'],
},
});

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
2026-01-25T16:07:02.209Z 2026-01-25T23:58:25.499Z;9248