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.
- 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 +34 -10
- package/package.json +1 -1
|
@@ -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
|
|
187
|
-
//
|
|
188
|
-
|
|
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,
|
|
204
|
+
"X-Protocol-Hash": PROTOCOL_HASH,
|
|
205
|
+
"X-Encrypted": "aes-256-gcm-v1", // señal al receptor
|
|
196
206
|
},
|
|
197
|
-
body: JSON.stringify(
|
|
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
|
|
350
|
+
let rawBody;
|
|
341
351
|
try {
|
|
342
|
-
|
|
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