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.
- package/dist/crypto/gossip-cipher.d.ts +69 -0
- package/dist/crypto/gossip-cipher.js +123 -0
- package/dist/crypto/peer-router.d.ts +59 -0
- package/dist/crypto/peer-router.js +98 -0
- package/dist/validator.js +86 -11
- package/package.json +3 -3
|
@@ -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
|
|
187
|
-
|
|
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: {
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
350
|
+
let rawBody;
|
|
321
351
|
try {
|
|
322
|
-
|
|
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.
|
|
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.
|
|
48
|
-
"soulprint-zkp": "0.1.
|
|
47
|
+
"soulprint-core": "0.1.7",
|
|
48
|
+
"soulprint-zkp": "0.1.4"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^20.0.0",
|