soulprint-network 0.2.2 → 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
@@ -2,9 +2,11 @@ import { createServer } from "node:http";
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
- import { generateKeypair, keypairFromPrivateKey, decodeToken, sign, verifyAttestation, computeReputation, defaultReputation, PROTOCOL, checkFarming, recordApprovedGain, recordFarmingStrike, loadAuditStore, exportAuditStore, } from "soulprint-core";
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,13 +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
- 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 });
188
198
  for (const peerUrl of targets) {
189
199
  fetch(`${peerUrl}/reputation/attest`, {
190
200
  method: "POST",
191
- headers: { "Content-Type": "application/json", "X-Gossip": "1" },
192
- body: JSON.stringify({ attestation: att }),
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ "X-Gossip": "1",
204
+ "X-Protocol-Hash": PROTOCOL_HASH,
205
+ "X-Encrypted": "aes-256-gcm-v1", // señal al receptor
206
+ },
207
+ body: JSON.stringify(encrypted),
193
208
  signal: AbortSignal.timeout(GOSSIP_TIMEOUT_MS),
194
209
  }).catch(() => { });
195
210
  }
@@ -248,11 +263,12 @@ function handleInfo(res, nodeKeypair) {
248
263
  node_did: nodeKeypair.did,
249
264
  version: VERSION,
250
265
  protocol: PROTOCOL.VERSION,
266
+ protocol_hash: PROTOCOL_HASH, // ← cualquier modificación cambia este hash
251
267
  total_verified: Object.keys(nullifiers).length,
252
268
  total_reputation: Object.keys(repStore).length,
253
269
  known_peers: peers.length,
254
270
  supported_countries: ["CO"],
255
- capabilities: ["zk-verify", "anti-sybil", "co-sign", "bot-reputation", "p2p-gossipsub"],
271
+ capabilities: ["zk-verify", "anti-sybil", "co-sign", "bot-reputation", "p2p-gossipsub", "credential-validators", "anti-farming"],
256
272
  rate_limit: `${PROTOCOL.RATE_LIMIT_MAX} req/min per IP`,
257
273
  // P2P stats (Phase 5)
258
274
  p2p: p2pStats ? {
@@ -275,6 +291,11 @@ function handleInfo(res, nodeKeypair) {
275
291
  function handleProtocol(res) {
276
292
  json(res, 200, {
277
293
  protocol_version: PROTOCOL.VERSION,
294
+ // ── Protocol Hash — IDENTIDAD DE LA RED ────────────────────────────────
295
+ // Cualquier nodo con un hash diferente es rechazado automáticamente.
296
+ // Si PROTOCOL fue modificado (aunque sea un valor), este hash cambia.
297
+ protocol_hash: PROTOCOL_HASH,
298
+ // ── Score limits ────────────────────────────────────────────────────────
278
299
  score_floor: PROTOCOL.SCORE_FLOOR,
279
300
  verified_score_floor: PROTOCOL.VERIFIED_SCORE_FLOOR,
280
301
  min_attester_score: PROTOCOL.MIN_ATTESTER_SCORE,
@@ -282,11 +303,20 @@ function handleProtocol(res) {
282
303
  reputation_max: PROTOCOL.REPUTATION_MAX,
283
304
  max_score: PROTOCOL.MAX_SCORE,
284
305
  default_reputation: PROTOCOL.DEFAULT_REPUTATION,
306
+ // ── Biometric thresholds ────────────────────────────────────────────────
307
+ face_sim_doc_selfie: PROTOCOL.FACE_SIM_DOC_SELFIE,
308
+ face_sim_selfie_selfie: PROTOCOL.FACE_SIM_SELFIE_SELFIE,
309
+ face_key_dims: PROTOCOL.FACE_KEY_DIMS,
310
+ face_key_precision: PROTOCOL.FACE_KEY_PRECISION,
311
+ // ── Retry / timing ──────────────────────────────────────────────────────
285
312
  verify_retry_max: PROTOCOL.VERIFY_RETRY_MAX,
286
313
  verify_retry_base_ms: PROTOCOL.VERIFY_RETRY_BASE_MS,
287
314
  verify_retry_max_ms: PROTOCOL.VERIFY_RETRY_MAX_MS,
288
315
  att_max_age_seconds: PROTOCOL.ATT_MAX_AGE_SECONDS,
289
- immutable: true, // todas estas constantes son inamovibles por diseño
316
+ // ── Enforcement notice ──────────────────────────────────────────────────
317
+ immutable: true,
318
+ enforcement: "p2p-hash", // ← la red rechaza nodos con hash diferente
319
+ note: "Nodes with a different protocol_hash are rejected by the network. Modifying any constant changes the hash and isolates the node.",
290
320
  });
291
321
  }
292
322
  // ── GET /reputation/:did ──────────────────────────────────────────────────────
@@ -317,16 +347,45 @@ function handleGetReputation(res, did) {
317
347
  async function handleAttest(req, res, ip) {
318
348
  if (!checkRateLimit(ip))
319
349
  return json(res, 429, { error: "Rate limit exceeded" });
320
- let body;
350
+ let rawBody;
321
351
  try {
322
- body = await readBody(req);
352
+ rawBody = await readBody(req);
323
353
  }
324
354
  catch (e) {
325
355
  return json(res, 400, { error: e.message });
326
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
+ }
327
374
  const { attestation, service_spt, from_peer } = body ?? {};
328
375
  if (!attestation)
329
376
  return json(res, 400, { error: "Missing field: attestation" });
377
+ // ── Protocol Hash Enforcement (gossip desde peers) ────────────────────────
378
+ if (from_peer) {
379
+ const peerHash = req.headers["x-protocol-hash"];
380
+ if (peerHash && !isProtocolHashCompatible(peerHash)) {
381
+ console.warn(`[protocol] Gossip rechazado de ${ip} — hash incompatible: ${peerHash?.slice(0, 16)}...`);
382
+ return json(res, 409, {
383
+ error: "Protocol mismatch — gossip rejected",
384
+ our_hash: PROTOCOL_HASH,
385
+ their_hash: peerHash,
386
+ });
387
+ }
388
+ }
330
389
  const att = attestation;
331
390
  // ── Validaciones básicas de la attestation ────────────────────────────────
332
391
  if (typeof att.issuer_did !== "string")
@@ -422,16 +481,30 @@ async function handlePeerRegister(req, res) {
422
481
  catch (e) {
423
482
  return json(res, 400, { error: e.message });
424
483
  }
425
- const { url } = body ?? {};
484
+ const { url, protocol_hash } = body ?? {};
426
485
  if (!url || typeof url !== "string")
427
486
  return json(res, 400, { error: "Missing field: url" });
428
487
  if (!/^https?:\/\//.test(url))
429
488
  return json(res, 400, { error: "url must start with http:// or https://" });
489
+ // ── Protocol Hash Enforcement — INAMOVIBLE POR LA RED ────────────────────
490
+ // Si el peer envía un hash, DEBE coincidir con el nuestro.
491
+ // Si no envía hash → se acepta (nodos legacy / primeras versiones).
492
+ // En versiones futuras, el hash será OBLIGATORIO.
493
+ if (protocol_hash && !isProtocolHashCompatible(protocol_hash)) {
494
+ return json(res, 409, {
495
+ error: "Protocol mismatch — node rejected",
496
+ reason: "The peer is running with different protocol constants. This breaks network consensus.",
497
+ our_hash: PROTOCOL_HASH,
498
+ their_hash: protocol_hash,
499
+ our_version: PROTOCOL.VERSION,
500
+ resolution: "Update soulprint-network to the latest version, or join a compatible network.",
501
+ });
502
+ }
430
503
  if (peers.includes(url))
431
504
  return json(res, 200, { ok: true, peers: peers.length, msg: "Already registered" });
432
505
  peers.push(url);
433
506
  savePeers();
434
- json(res, 200, { ok: true, peers: peers.length });
507
+ json(res, 200, { ok: true, peers: peers.length, protocol_hash: PROTOCOL_HASH });
435
508
  }
436
509
  // ── GET /peers ─────────────────────────────────────────────────────────────────
437
510
  function handleGetPeers(res) {
@@ -579,6 +652,8 @@ export function startValidatorNode(port = PORT) {
579
652
  console.log(`\n🌐 Soulprint Validator Node v${VERSION}`);
580
653
  console.log(` Node DID: ${nodeKeypair.did}`);
581
654
  console.log(` Listening: http://0.0.0.0:${port}`);
655
+ console.log(` Protocol: ${PROTOCOL.VERSION} | hash: ${PROTOCOL_HASH.slice(0, 16)}...`);
656
+ console.log(` ⚠️ Hash mismatch with peers → connection rejected (P2P enforcement)`);
582
657
  console.log(` Nullifiers: ${Object.keys(nullifiers).length}`);
583
658
  console.log(` Reputations: ${Object.keys(repStore).length}`);
584
659
  console.log(` Known peers: ${peers.length}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulprint-network",
3
- "version": "0.2.2",
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",
@@ -44,8 +44,8 @@
44
44
  "otpauth": "^9.5.0",
45
45
  "otplib": "^13.3.0",
46
46
  "uint8arrays": "5.1.0",
47
- "soulprint-core": "0.1.5",
48
- "soulprint-zkp": "0.1.3"
47
+ "soulprint-core": "0.1.7",
48
+ "soulprint-zkp": "0.1.4"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^20.0.0",