soulprint-network 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * gossip-cipher.ts — Cifrado end-to-end para el tráfico P2P de Soulprint
3
+ *
4
+ * DISEÑO:
5
+ * ─────────────────────────────────────────────────────────────────────────
6
+ * • Algoritmo: AES-256-GCM (AEAD — autenticado + cifrado)
7
+ * • Clave derivada de: HMAC-SHA256(PROTOCOL_HASH + epoch)
8
+ * → Solo nodos con PROTOCOL_HASH correcto pueden cifrar/descifrar
9
+ * → Refuerza el hash enforcement: no basta con conocer el hash,
10
+ * el hash correcto ES la clave de acceso a la red
11
+ * • Rotación: epoch de 5 minutos → forward secrecy básica
12
+ * • Nonce: 96 bits aleatorios por mensaje (GCM requirement)
13
+ * • AuthTag: 128 bits — detecta cualquier tampering en tránsito
14
+ *
15
+ * FLUJO:
16
+ * Emisor: payload → encryptGossip() → { ciphertext, nonce, epoch, tag }
17
+ * Receptor: → decryptGossip() → payload original ← solo si tiene hash correcto
18
+ *
19
+ * VENTAJAS SOBRE PLAINTEXT:
20
+ * 1. Un nodo modificado (hash diferente) no puede leer el tráfico de la red
21
+ * 2. Un atacante MitM no puede modificar attestations (AuthTag falla)
22
+ * 3. Replay protection: epoch cambia cada 5 min, aceptamos ±1 epoch
23
+ * 4. Doble enforcement: hash = identidad de red + llave de cifrado
24
+ */
25
+ /**
26
+ * Deriva la clave AES-256 para el epoch dado.
27
+ * PROTOCOL_HASH es el secreto compartido — solo nodos honestos lo tienen.
28
+ *
29
+ * @param epochMs Timestamp del epoch (default: now)
30
+ */
31
+ export declare function deriveGossipKey(epochMs?: number): Buffer;
32
+ /**
33
+ * Retorna el epoch actual y el anterior (para ventana de tolerancia).
34
+ */
35
+ export declare function currentEpochs(): number[];
36
+ export interface EncryptedGossip {
37
+ /** Payload cifrado + AuthTag (base64). */
38
+ ct: string;
39
+ /** Nonce aleatorio de 96 bits (base64). */
40
+ iv: string;
41
+ /** Epoch en que fue cifrado (para seleccionar la clave correcta). */
42
+ ep: number;
43
+ /** Versión del esquema de cifrado. */
44
+ v: 1;
45
+ }
46
+ /**
47
+ * Cifra un payload de gossip con AES-256-GCM.
48
+ * Solo nodos con el PROTOCOL_HASH correcto pueden descifrar.
49
+ *
50
+ * @param payload Objeto JSON a cifrar
51
+ * @returns EncryptedGossip listo para transmitir
52
+ */
53
+ export declare function encryptGossip(payload: object): EncryptedGossip;
54
+ export interface DecryptResult {
55
+ ok: boolean;
56
+ payload?: object;
57
+ error?: string;
58
+ }
59
+ /**
60
+ * Descifra un payload de gossip recibido de un peer.
61
+ *
62
+ * Valida:
63
+ * 1. Epoch dentro de la ventana aceptada (±EPOCH_TOLERANCE epochs)
64
+ * 2. AuthTag GCM — rechaza cualquier mensaje tampered
65
+ * 3. JSON válido
66
+ *
67
+ * @param enc EncryptedGossip recibido del peer
68
+ */
69
+ export declare function decryptGossip(enc: EncryptedGossip): DecryptResult;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * gossip-cipher.ts — Cifrado end-to-end para el tráfico P2P de Soulprint
3
+ *
4
+ * DISEÑO:
5
+ * ─────────────────────────────────────────────────────────────────────────
6
+ * • Algoritmo: AES-256-GCM (AEAD — autenticado + cifrado)
7
+ * • Clave derivada de: HMAC-SHA256(PROTOCOL_HASH + epoch)
8
+ * → Solo nodos con PROTOCOL_HASH correcto pueden cifrar/descifrar
9
+ * → Refuerza el hash enforcement: no basta con conocer el hash,
10
+ * el hash correcto ES la clave de acceso a la red
11
+ * • Rotación: epoch de 5 minutos → forward secrecy básica
12
+ * • Nonce: 96 bits aleatorios por mensaje (GCM requirement)
13
+ * • AuthTag: 128 bits — detecta cualquier tampering en tránsito
14
+ *
15
+ * FLUJO:
16
+ * Emisor: payload → encryptGossip() → { ciphertext, nonce, epoch, tag }
17
+ * Receptor: → decryptGossip() → payload original ← solo si tiene hash correcto
18
+ *
19
+ * VENTAJAS SOBRE PLAINTEXT:
20
+ * 1. Un nodo modificado (hash diferente) no puede leer el tráfico de la red
21
+ * 2. Un atacante MitM no puede modificar attestations (AuthTag falla)
22
+ * 3. Replay protection: epoch cambia cada 5 min, aceptamos ±1 epoch
23
+ * 4. Doble enforcement: hash = identidad de red + llave de cifrado
24
+ */
25
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes, } from "node:crypto";
26
+ import { PROTOCOL_HASH } from "soulprint-core";
27
+ // ── Constantes ────────────────────────────────────────────────────────────────
28
+ /** Duración de cada epoch de cifrado (5 minutos). */
29
+ const EPOCH_MS = 5 * 60 * 1000;
30
+ /** Epochs aceptados en recepción: actual ± EPOCH_TOLERANCE. */
31
+ const EPOCH_TOLERANCE = 1;
32
+ /** Tamaño del nonce GCM (96 bits — recomendación NIST). */
33
+ const NONCE_BYTES = 12;
34
+ /** Tamaño del AuthTag GCM (128 bits — máximo, más seguro). */
35
+ const AUTH_TAG_BYTES = 16;
36
+ // ── Key derivation ────────────────────────────────────────────────────────────
37
+ /**
38
+ * Deriva la clave AES-256 para el epoch dado.
39
+ * PROTOCOL_HASH es el secreto compartido — solo nodos honestos lo tienen.
40
+ *
41
+ * @param epochMs Timestamp del epoch (default: now)
42
+ */
43
+ export function deriveGossipKey(epochMs = Date.now()) {
44
+ const epoch = Math.floor(epochMs / EPOCH_MS);
45
+ const material = `soulprint-gossip-v1:${PROTOCOL_HASH}:epoch:${epoch}`;
46
+ return createHmac("sha256", PROTOCOL_HASH).update(material).digest();
47
+ }
48
+ /**
49
+ * Retorna el epoch actual y el anterior (para ventana de tolerancia).
50
+ */
51
+ export function currentEpochs() {
52
+ const now = Date.now();
53
+ const epoch = Math.floor(now / EPOCH_MS);
54
+ return Array.from({ length: EPOCH_TOLERANCE * 2 + 1 }, (_, i) => epoch - EPOCH_TOLERANCE + i);
55
+ }
56
+ /**
57
+ * Cifra un payload de gossip con AES-256-GCM.
58
+ * Solo nodos con el PROTOCOL_HASH correcto pueden descifrar.
59
+ *
60
+ * @param payload Objeto JSON a cifrar
61
+ * @returns EncryptedGossip listo para transmitir
62
+ */
63
+ export function encryptGossip(payload) {
64
+ const epoch = Math.floor(Date.now() / EPOCH_MS);
65
+ const key = deriveGossipKey(Date.now());
66
+ const nonce = randomBytes(NONCE_BYTES);
67
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
68
+ cipher.setAAD(Buffer.from(`epoch:${epoch}`)); // Additional data autenticada
69
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
70
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
71
+ const authTag = cipher.getAuthTag(); // 16 bytes
72
+ // ciphertext = encrypted || authTag (concatenados)
73
+ return {
74
+ ct: Buffer.concat([encrypted, authTag]).toString("base64"),
75
+ iv: nonce.toString("base64"),
76
+ ep: epoch,
77
+ v: 1,
78
+ };
79
+ }
80
+ /**
81
+ * Descifra un payload de gossip recibido de un peer.
82
+ *
83
+ * Valida:
84
+ * 1. Epoch dentro de la ventana aceptada (±EPOCH_TOLERANCE epochs)
85
+ * 2. AuthTag GCM — rechaza cualquier mensaje tampered
86
+ * 3. JSON válido
87
+ *
88
+ * @param enc EncryptedGossip recibido del peer
89
+ */
90
+ export function decryptGossip(enc) {
91
+ if (enc.v !== 1)
92
+ return { ok: false, error: "Unknown cipher version" };
93
+ // Validar epoch
94
+ const validEpochs = currentEpochs();
95
+ if (!validEpochs.includes(enc.ep)) {
96
+ return {
97
+ ok: false,
98
+ error: `Epoch ${enc.ep} fuera de ventana aceptada [${validEpochs[0]}–${validEpochs[validEpochs.length - 1]}]`,
99
+ };
100
+ }
101
+ try {
102
+ const key = deriveGossipKey(enc.ep * EPOCH_MS);
103
+ const nonce = Buffer.from(enc.iv, "base64");
104
+ const ctAndTag = Buffer.from(enc.ct, "base64");
105
+ // Separar ciphertext del authTag
106
+ const ciphertext = ctAndTag.subarray(0, ctAndTag.length - AUTH_TAG_BYTES);
107
+ const authTag = ctAndTag.subarray(ctAndTag.length - AUTH_TAG_BYTES);
108
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
109
+ decipher.setAuthTag(authTag);
110
+ decipher.setAAD(Buffer.from(`epoch:${enc.ep}`));
111
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
112
+ const payload = JSON.parse(decrypted.toString("utf8"));
113
+ return { ok: true, payload };
114
+ }
115
+ catch (err) {
116
+ return {
117
+ ok: false,
118
+ error: err.message?.includes("Unsupported state")
119
+ ? "AuthTag inválido — mensaje tampered o clave incorrecta"
120
+ : `Decrypt error: ${err.message?.slice(0, 80)}`,
121
+ };
122
+ }
123
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * peer-router.ts — Routing XOR para búsqueda de peers eficiente
3
+ *
4
+ * PROBLEMA CON BROADCAST TOTAL:
5
+ * ─────────────────────────────────────────────────────────────────────────
6
+ * Gossip actual: envía attestation a TODOS los peers → O(n)
7
+ * Con 100 nodos: 100 requests por attestation
8
+ * Con 1000 nodos: 1000 requests — no escala
9
+ *
10
+ * SOLUCIÓN: Kademlia-style XOR routing
11
+ * ─────────────────────────────────────────────────────────────────────────
12
+ * • Cada nodo tiene un ID de 32 bytes (SHA-256 de su URL)
13
+ * • Distancia entre dos nodos = XOR de sus IDs (métrica Kademlia)
14
+ * • Para gossip de una attestation sobre DID X:
15
+ * → Seleccionar K peers más cercanos a SHA-256(X)
16
+ * → En redes grandes: O(log n) en lugar de O(n)
17
+ * • Bucket table: 256 buckets por bit position
18
+ *
19
+ * CONFIGURACIÓN:
20
+ * • K_FACTOR = 6: enviar a los 6 peers más cercanos al target
21
+ * • ALPHA = 3: paralelismo de búsqueda (como Kademlia estándar)
22
+ * • FULL_BROADCAST_THRESHOLD = 10: con ≤10 peers, broadcast total
23
+ * (no vale la pena routing con pocos nodos)
24
+ */
25
+ /** Peers más cercanos a seleccionar para gossip dirigido. */
26
+ export declare const K_FACTOR = 6;
27
+ /** Umbral de peers bajo el cual se hace broadcast total (más simple). */
28
+ export declare const FULL_BROADCAST_THRESHOLD = 10;
29
+ /**
30
+ * Deriva un ID de 32 bytes para una URL o DID.
31
+ * Determinístico: misma entrada → mismo ID.
32
+ */
33
+ export declare function nodeId(input: string): Buffer;
34
+ /**
35
+ * Calcula la distancia XOR entre dos IDs (Buffer de 32 bytes).
36
+ * Menor distancia = más cercano en el espacio Kademlia.
37
+ */
38
+ export declare function xorDistance(a: Buffer, b: Buffer): Buffer;
39
+ /**
40
+ * Compara dos distancias XOR.
41
+ * @returns negativo si a < b, 0 si iguales, positivo si a > b
42
+ */
43
+ export declare function compareDistance(a: Buffer, b: Buffer): number;
44
+ /**
45
+ * Selecciona los K peers más cercanos al target para gossip dirigido.
46
+ *
47
+ * Con ≤ FULL_BROADCAST_THRESHOLD peers: retorna todos (broadcast).
48
+ * Con más peers: retorna los K más cercanos al targetId (XOR routing).
49
+ *
50
+ * @param peers Lista de URLs de peers conocidos
51
+ * @param targetDid DID del bot sobre el que se gossipea (target del routing)
52
+ * @param exclude URLs a excluir (ej: el peer que nos envió el mensaje)
53
+ * @returns Subconjunto de peers seleccionados para gossip
54
+ */
55
+ export declare function selectGossipPeers(peers: string[], targetDid: string, exclude?: string): string[];
56
+ /**
57
+ * Calcula estadísticas del routing para logging.
58
+ */
59
+ export declare function routingStats(totalPeers: number, selectedPeers: number, targetDid: string): string;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * peer-router.ts — Routing XOR para búsqueda de peers eficiente
3
+ *
4
+ * PROBLEMA CON BROADCAST TOTAL:
5
+ * ─────────────────────────────────────────────────────────────────────────
6
+ * Gossip actual: envía attestation a TODOS los peers → O(n)
7
+ * Con 100 nodos: 100 requests por attestation
8
+ * Con 1000 nodos: 1000 requests — no escala
9
+ *
10
+ * SOLUCIÓN: Kademlia-style XOR routing
11
+ * ─────────────────────────────────────────────────────────────────────────
12
+ * • Cada nodo tiene un ID de 32 bytes (SHA-256 de su URL)
13
+ * • Distancia entre dos nodos = XOR de sus IDs (métrica Kademlia)
14
+ * • Para gossip de una attestation sobre DID X:
15
+ * → Seleccionar K peers más cercanos a SHA-256(X)
16
+ * → En redes grandes: O(log n) en lugar de O(n)
17
+ * • Bucket table: 256 buckets por bit position
18
+ *
19
+ * CONFIGURACIÓN:
20
+ * • K_FACTOR = 6: enviar a los 6 peers más cercanos al target
21
+ * • ALPHA = 3: paralelismo de búsqueda (como Kademlia estándar)
22
+ * • FULL_BROADCAST_THRESHOLD = 10: con ≤10 peers, broadcast total
23
+ * (no vale la pena routing con pocos nodos)
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ // ── Constantes ────────────────────────────────────────────────────────────────
27
+ /** Peers más cercanos a seleccionar para gossip dirigido. */
28
+ export const K_FACTOR = 6;
29
+ /** Umbral de peers bajo el cual se hace broadcast total (más simple). */
30
+ export const FULL_BROADCAST_THRESHOLD = 10;
31
+ // ── Node ID ───────────────────────────────────────────────────────────────────
32
+ /**
33
+ * Deriva un ID de 32 bytes para una URL o DID.
34
+ * Determinístico: misma entrada → mismo ID.
35
+ */
36
+ export function nodeId(input) {
37
+ return createHash("sha256").update(input).digest();
38
+ }
39
+ /**
40
+ * Calcula la distancia XOR entre dos IDs (Buffer de 32 bytes).
41
+ * Menor distancia = más cercano en el espacio Kademlia.
42
+ */
43
+ export function xorDistance(a, b) {
44
+ const result = Buffer.allocUnsafe(32);
45
+ for (let i = 0; i < 32; i++) {
46
+ result[i] = a[i] ^ b[i];
47
+ }
48
+ return result;
49
+ }
50
+ /**
51
+ * Compara dos distancias XOR.
52
+ * @returns negativo si a < b, 0 si iguales, positivo si a > b
53
+ */
54
+ export function compareDistance(a, b) {
55
+ for (let i = 0; i < 32; i++) {
56
+ if (a[i] !== b[i])
57
+ return a[i] - b[i];
58
+ }
59
+ return 0;
60
+ }
61
+ // ── Peer selection ────────────────────────────────────────────────────────────
62
+ /**
63
+ * Selecciona los K peers más cercanos al target para gossip dirigido.
64
+ *
65
+ * Con ≤ FULL_BROADCAST_THRESHOLD peers: retorna todos (broadcast).
66
+ * Con más peers: retorna los K más cercanos al targetId (XOR routing).
67
+ *
68
+ * @param peers Lista de URLs de peers conocidos
69
+ * @param targetDid DID del bot sobre el que se gossipea (target del routing)
70
+ * @param exclude URLs a excluir (ej: el peer que nos envió el mensaje)
71
+ * @returns Subconjunto de peers seleccionados para gossip
72
+ */
73
+ export function selectGossipPeers(peers, targetDid, exclude) {
74
+ const candidates = exclude ? peers.filter(p => p !== exclude) : [...peers];
75
+ // Con pocos peers, broadcast total (más robusto)
76
+ if (candidates.length <= FULL_BROADCAST_THRESHOLD) {
77
+ return candidates;
78
+ }
79
+ // XOR routing: ordenar por distancia al target
80
+ const targetBuf = nodeId(targetDid);
81
+ const sorted = candidates
82
+ .map(url => ({
83
+ url,
84
+ dist: xorDistance(nodeId(url), targetBuf),
85
+ }))
86
+ .sort((a, b) => compareDistance(a.dist, b.dist))
87
+ .slice(0, K_FACTOR)
88
+ .map(({ url }) => url);
89
+ return sorted;
90
+ }
91
+ /**
92
+ * Calcula estadísticas del routing para logging.
93
+ */
94
+ export function routingStats(totalPeers, selectedPeers, targetDid) {
95
+ const ratio = ((selectedPeers / totalPeers) * 100).toFixed(0);
96
+ const mode = totalPeers <= FULL_BROADCAST_THRESHOLD ? "broadcast" : `xor-routing(k=${K_FACTOR})`;
97
+ return `[routing] ${selectedPeers}/${totalPeers} peers (${ratio}%) via ${mode} → ${targetDid.slice(0, 16)}...`;
98
+ }
package/dist/validator.js CHANGED
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
5
5
  import { generateKeypair, keypairFromPrivateKey, decodeToken, sign, verifyAttestation, computeReputation, defaultReputation, PROTOCOL, PROTOCOL_HASH, isProtocolHashCompatible, checkFarming, recordApprovedGain, recordFarmingStrike, loadAuditStore, exportAuditStore, } from "soulprint-core";
6
6
  import { verifyProof, deserializeProof } from "soulprint-zkp";
7
7
  import { handleCredentialRoute } from "./credentials/index.js";
8
+ import { encryptGossip, decryptGossip } from "./crypto/gossip-cipher.js";
9
+ import { selectGossipPeers, routingStats } from "./crypto/peer-router.js";
8
10
  import { publishAttestationP2P, onAttestationReceived, getP2PStats, } from "./p2p.js";
9
11
  // ── Config ────────────────────────────────────────────────────────────────────
10
12
  const PORT = parseInt(process.env.SOULPRINT_PORT ?? String(PROTOCOL.DEFAULT_HTTP_PORT));
@@ -183,18 +185,26 @@ async function gossipAttestation(att, excludeUrl) {
183
185
  console.log(`[p2p] Attestation publicada → ${recipients} peer(s) via GossipSub`);
184
186
  }
185
187
  }
186
- // ── Canal 2: HTTP gossip (fallback para nodos legacy) ─────────────────────
187
- // Incluye X-Protocol-Hash para que el peer receptor valide compatibilidad.
188
- const targets = peers.filter(p => p !== excludeUrl);
188
+ // ── Canal 2: HTTP gossip con cifrado AES-256-GCM + XOR routing ────────────
189
+ // Selección de peers: XOR routing hacia el DID objetivo O(log n)
190
+ // Con ≤10 peers: broadcast total. Con más: solo K=6 más cercanos.
191
+ const targets = selectGossipPeers(peers, att.target_did, excludeUrl);
192
+ if (targets.length < peers.length - (excludeUrl ? 1 : 0)) {
193
+ console.log(routingStats(peers.length, targets.length, att.target_did));
194
+ }
195
+ // Cifrar el payload con AES-256-GCM antes de enviar
196
+ // Solo nodos con PROTOCOL_HASH correcto pueden descifrar
197
+ const encrypted = encryptGossip({ attestation: att, from_peer: true });
189
198
  for (const peerUrl of targets) {
190
199
  fetch(`${peerUrl}/reputation/attest`, {
191
200
  method: "POST",
192
201
  headers: {
193
202
  "Content-Type": "application/json",
194
203
  "X-Gossip": "1",
195
- "X-Protocol-Hash": PROTOCOL_HASH, // ← el receptor valida esto
204
+ "X-Protocol-Hash": PROTOCOL_HASH,
205
+ "X-Encrypted": "aes-256-gcm-v1", // señal al receptor
196
206
  },
197
- body: JSON.stringify({ attestation: att, from_peer: true }),
207
+ body: JSON.stringify(encrypted),
198
208
  signal: AbortSignal.timeout(GOSSIP_TIMEOUT_MS),
199
209
  }).catch(() => { });
200
210
  }
@@ -337,20 +347,34 @@ function handleGetReputation(res, did) {
337
347
  async function handleAttest(req, res, ip) {
338
348
  if (!checkRateLimit(ip))
339
349
  return json(res, 429, { error: "Rate limit exceeded" });
340
- let body;
350
+ let rawBody;
341
351
  try {
342
- body = await readBody(req);
352
+ rawBody = await readBody(req);
343
353
  }
344
354
  catch (e) {
345
355
  return json(res, 400, { error: e.message });
346
356
  }
357
+ // ── Descifrado AES-256-GCM (gossip cifrado desde peers) ──────────────────
358
+ // Si el header X-Encrypted está presente, descifrar antes de procesar.
359
+ // Un nodo con PROTOCOL_HASH diferente no puede descifrar → falla aquí.
360
+ let body = rawBody;
361
+ const isEncrypted = req.headers["x-encrypted"] === "aes-256-gcm-v1";
362
+ if (isEncrypted) {
363
+ const result = decryptGossip(rawBody);
364
+ if (!result.ok) {
365
+ console.warn(`[crypto] Gossip descifrado fallido desde ${ip}: ${result.error}`);
366
+ return json(res, 403, {
367
+ error: "Encrypted gossip could not be decrypted",
368
+ reason: result.error,
369
+ hint: "Ensure your node runs the official soulprint-network with the correct PROTOCOL_HASH",
370
+ });
371
+ }
372
+ body = result.payload;
373
+ }
347
374
  const { attestation, service_spt, from_peer } = body ?? {};
348
375
  if (!attestation)
349
376
  return json(res, 400, { error: "Missing field: attestation" });
350
377
  // ── Protocol Hash Enforcement (gossip desde peers) ────────────────────────
351
- // Si la attestation viene de un peer (X-Gossip: 1), validamos que el peer
352
- // opera con las mismas constantes de protocolo.
353
- // Un nodo con constantes modificadas no puede inyectar attestations en la red.
354
378
  if (from_peer) {
355
379
  const peerHash = req.headers["x-protocol-hash"];
356
380
  if (peerHash && !isProtocolHashCompatible(peerHash)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulprint-network",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Soulprint validator node — HTTP server that verifies ZK proofs, co-signs SPTs, anti-Sybil registry",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",