yakmesh 2.8.2 → 3.0.0

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.
Files changed (232) hide show
  1. package/CHANGELOG.md +637 -0
  2. package/CONTRIBUTING.md +42 -0
  3. package/Caddyfile +77 -0
  4. package/README.md +119 -29
  5. package/adapters/adapter-mlv-bible/README.md +124 -0
  6. package/adapters/adapter-mlv-bible/index.js +400 -0
  7. package/adapters/chat-mod-adapter.js +532 -0
  8. package/adapters/content-adapter.js +273 -0
  9. package/content/api.js +50 -41
  10. package/content/index.js +2 -2
  11. package/content/store.js +355 -173
  12. package/dashboard/index.html +19 -3
  13. package/database/replication.js +117 -37
  14. package/docs/CRYPTO-AGILITY.md +204 -0
  15. package/docs/MTLS-RESEARCH.md +367 -0
  16. package/docs/NAMCHE-SPEC.md +681 -0
  17. package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
  18. package/docs/PRECISION-DISCLOSURE.md +96 -0
  19. package/docs/README.md +76 -0
  20. package/docs/ROADMAP-2.4.0.md +447 -0
  21. package/docs/ROADMAP-2.5.0.md +244 -0
  22. package/docs/SECURITY-AUDIT-REPORT.md +306 -0
  23. package/docs/SST-INTEGRATION.md +712 -0
  24. package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
  25. package/docs/TERNARY-AUDIT-REPORT.md +247 -0
  26. package/docs/TME-FAQ.md +221 -0
  27. package/docs/WHITEPAPER.md +623 -0
  28. package/docs/adapters.html +1001 -0
  29. package/docs/advanced-systems.html +1045 -0
  30. package/docs/annex.html +1046 -0
  31. package/docs/api.html +970 -0
  32. package/docs/business/response-templates.md +160 -0
  33. package/docs/c2c.html +1225 -0
  34. package/docs/cli.html +1332 -0
  35. package/docs/configuration.html +1248 -0
  36. package/docs/darshan.html +1085 -0
  37. package/docs/dharma.html +966 -0
  38. package/docs/docs-bundle.html +1075 -0
  39. package/docs/docs.css +3120 -0
  40. package/docs/docs.js +556 -0
  41. package/docs/doko.html +969 -0
  42. package/docs/geo-proof.html +858 -0
  43. package/docs/getting-started.html +840 -0
  44. package/docs/gumba-tutorial.html +1144 -0
  45. package/docs/gumba.html +1098 -0
  46. package/docs/index.html +914 -0
  47. package/docs/jhilke.html +1312 -0
  48. package/docs/karma.html +1100 -0
  49. package/docs/katha.html +1037 -0
  50. package/docs/lama.html +978 -0
  51. package/docs/mandala.html +1067 -0
  52. package/docs/mani.html +964 -0
  53. package/docs/mantra.html +967 -0
  54. package/docs/mesh.html +1409 -0
  55. package/docs/nakpak.html +869 -0
  56. package/docs/namche.html +928 -0
  57. package/docs/nav-order.json +53 -0
  58. package/docs/prahari.html +1043 -0
  59. package/docs/prism-bash.min.js +1 -0
  60. package/docs/prism-javascript.min.js +1 -0
  61. package/docs/prism-json.min.js +1 -0
  62. package/docs/prism-tomorrow.min.css +1 -0
  63. package/docs/prism.min.js +1 -0
  64. package/docs/privacy.html +699 -0
  65. package/docs/quick-reference.html +1181 -0
  66. package/docs/sakshi.html +1402 -0
  67. package/docs/sandboxing.md +386 -0
  68. package/docs/seva.html +911 -0
  69. package/docs/sherpa.html +871 -0
  70. package/docs/studio.html +860 -0
  71. package/docs/stupa.html +995 -0
  72. package/docs/tailwind.min.css +2 -0
  73. package/docs/tattva.html +1332 -0
  74. package/docs/terms.html +686 -0
  75. package/docs/time-server-deployment.md +166 -0
  76. package/docs/time-sources.html +1392 -0
  77. package/docs/tivra.html +1127 -0
  78. package/docs/trademark-policy.html +686 -0
  79. package/docs/tribhuj.html +1183 -0
  80. package/docs/trust-security.html +1029 -0
  81. package/docs/tutorials/backup-recovery.html +654 -0
  82. package/docs/tutorials/dashboard.html +604 -0
  83. package/docs/tutorials/domain-setup.html +605 -0
  84. package/docs/tutorials/host-website.html +456 -0
  85. package/docs/tutorials/mesh-network.html +505 -0
  86. package/docs/tutorials/mobile-access.html +445 -0
  87. package/docs/tutorials/privacy.html +467 -0
  88. package/docs/tutorials/raspberry-pi.html +600 -0
  89. package/docs/tutorials/security-basics.html +539 -0
  90. package/docs/tutorials/share-files.html +431 -0
  91. package/docs/tutorials/troubleshooting.html +637 -0
  92. package/docs/tutorials/trust-karma.html +419 -0
  93. package/docs/tutorials/yak-protocol.html +456 -0
  94. package/docs/tutorials.html +1034 -0
  95. package/docs/vani.html +1270 -0
  96. package/docs/webserver.html +809 -0
  97. package/docs/yak-protocol.html +940 -0
  98. package/docs/yak-timeserver-design.md +475 -0
  99. package/docs/yakapp.html +1015 -0
  100. package/docs/ypc27.html +1069 -0
  101. package/docs/yurt.html +1344 -0
  102. package/embedded-docs/bundle.js +334 -74
  103. package/gossip/protocol.js +247 -27
  104. package/identity/key-resolver.js +262 -0
  105. package/identity/machine-seed.js +632 -0
  106. package/identity/node-key.js +669 -368
  107. package/identity/tribhuj-ratchet.js +506 -0
  108. package/knowledge-base.js +37 -8
  109. package/launcher/yakmesh.bat +62 -0
  110. package/launcher/yakmesh.sh +70 -0
  111. package/mesh/annex.js +462 -108
  112. package/mesh/beacon-broadcast.js +113 -1
  113. package/mesh/darshan.js +1718 -0
  114. package/mesh/gumba.js +1567 -0
  115. package/mesh/jhilke.js +651 -0
  116. package/mesh/katha.js +1012 -0
  117. package/mesh/nakpak-routing.js +8 -5
  118. package/mesh/network.js +724 -34
  119. package/mesh/pulse-sync.js +4 -1
  120. package/mesh/rate-limiter.js +127 -15
  121. package/mesh/seva.js +526 -0
  122. package/mesh/sherpa-discovery.js +89 -8
  123. package/mesh/sybil-defense.js +19 -5
  124. package/mesh/temporal-encoder.js +4 -3
  125. package/mesh/vani.js +1364 -0
  126. package/mesh/yurt.js +1340 -0
  127. package/models/entropy-sentinel.onnx +0 -0
  128. package/models/karma-trust.onnx +0 -0
  129. package/models/manifest.json +43 -0
  130. package/models/sakshi-anomaly.onnx +0 -0
  131. package/oracle/code-proof-protocol.js +7 -6
  132. package/oracle/codebase-lock.js +257 -28
  133. package/oracle/index.js +74 -15
  134. package/oracle/ma902-snmp.js +678 -0
  135. package/oracle/module-sealer.js +5 -3
  136. package/oracle/network-identity.js +16 -0
  137. package/oracle/packet-checksum.js +201 -0
  138. package/oracle/sst.js +579 -0
  139. package/oracle/ternary-144t.js +714 -0
  140. package/oracle/ternary-ml.js +481 -0
  141. package/oracle/time-api.js +239 -0
  142. package/oracle/time-source.js +137 -47
  143. package/oracle/validation-oracle-hardened.js +1111 -1071
  144. package/oracle/validation-oracle.js +4 -2
  145. package/oracle/ypc27.js +211 -0
  146. package/package.json +20 -3
  147. package/protocol/yak-handler.js +35 -9
  148. package/protocol/yak-protocol.js +28 -13
  149. package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
  150. package/reference/cpp/yakmesh_ypc27.cpp +179 -0
  151. package/sbom.json +87 -0
  152. package/scripts/security-audit.mjs +264 -0
  153. package/scripts/update-docs-nav.js +194 -0
  154. package/scripts/update-docs-sidebar.cjs +164 -0
  155. package/security/crypto-config.js +4 -3
  156. package/security/dharma-moderation.js +517 -0
  157. package/security/doko-identity.js +193 -143
  158. package/security/domain-consensus.js +86 -85
  159. package/security/fs-hardening.js +620 -0
  160. package/security/hardware-attestation.js +5 -3
  161. package/security/hybrid-trust.js +227 -87
  162. package/security/karma-rate-limiter.js +692 -0
  163. package/security/khata-protocol.js +22 -21
  164. package/security/khata-trust-integration.js +277 -150
  165. package/security/memory-safety.js +635 -0
  166. package/security/mesh-auth.js +11 -10
  167. package/security/mesh-revocation.js +373 -5
  168. package/security/namche-gateway.js +298 -69
  169. package/security/sakshi.js +460 -3
  170. package/security/sangha.js +770 -0
  171. package/security/secure-config.js +473 -0
  172. package/security/silicon-parity.js +13 -10
  173. package/security/steadywatch.js +1142 -0
  174. package/security/strike-system.js +32 -3
  175. package/security/temporal-signing.js +488 -0
  176. package/security/trit-commitment.js +464 -0
  177. package/server/crypto/annex.js +247 -0
  178. package/server/darshan-api.js +343 -0
  179. package/server/index.js +3259 -362
  180. package/server/komm-api.js +668 -0
  181. package/utils/accel.js +2273 -0
  182. package/utils/ternary-id.js +79 -0
  183. package/utils/verify-worker.js +57 -0
  184. package/webserver/index.js +95 -5
  185. package/assets/yakmesh-logo.png +0 -0
  186. package/assets/yakmesh-logo.svg +0 -80
  187. package/assets/yakmesh-logo2.png +0 -0
  188. package/assets/yakmesh-logo2sm.png +0 -0
  189. package/assets/ymsm.png +0 -0
  190. package/website/assets/silhouettes/adapters.svg +0 -107
  191. package/website/assets/silhouettes/api-endpoints.svg +0 -115
  192. package/website/assets/silhouettes/atomic-clock.svg +0 -83
  193. package/website/assets/silhouettes/base-camp.svg +0 -81
  194. package/website/assets/silhouettes/bridge.svg +0 -69
  195. package/website/assets/silhouettes/docs-bundle.svg +0 -113
  196. package/website/assets/silhouettes/doko-basket.svg +0 -70
  197. package/website/assets/silhouettes/fortress.svg +0 -93
  198. package/website/assets/silhouettes/gateway.svg +0 -54
  199. package/website/assets/silhouettes/gears.svg +0 -93
  200. package/website/assets/silhouettes/globe-satellite.svg +0 -67
  201. package/website/assets/silhouettes/karma-wheel.svg +0 -137
  202. package/website/assets/silhouettes/lama-council.svg +0 -141
  203. package/website/assets/silhouettes/mandala-network.svg +0 -169
  204. package/website/assets/silhouettes/mani-stones.svg +0 -149
  205. package/website/assets/silhouettes/mantra-wheel.svg +0 -116
  206. package/website/assets/silhouettes/mesh-nodes.svg +0 -113
  207. package/website/assets/silhouettes/nakpak.svg +0 -56
  208. package/website/assets/silhouettes/peak-lightning.svg +0 -73
  209. package/website/assets/silhouettes/sherpa.svg +0 -69
  210. package/website/assets/silhouettes/stupa-tower.svg +0 -119
  211. package/website/assets/silhouettes/tattva-eye.svg +0 -78
  212. package/website/assets/silhouettes/terminal.svg +0 -74
  213. package/website/assets/silhouettes/webserver.svg +0 -145
  214. package/website/assets/silhouettes/yak.svg +0 -78
  215. package/website/assets/yakmesh-logo.png +0 -0
  216. package/website/assets/yakmesh-logo.webp +0 -0
  217. package/website/assets/yakmesh-logo128x140.webp +0 -0
  218. package/website/assets/yakmesh-logo2.png +0 -0
  219. package/website/assets/yakmesh-logo2.svg +0 -51
  220. package/website/assets/yakmesh-logo40x44.webp +0 -0
  221. package/website/assets/yakmesh.gif +0 -0
  222. package/website/assets/yakmesh.ico +0 -0
  223. package/website/assets/yakmesh.jpg +0 -0
  224. package/website/assets/yakmesh.pdf +0 -0
  225. package/website/assets/yakmesh.png +0 -0
  226. package/website/assets/yakmesh.svg +0 -70
  227. package/website/assets/yakmesh128.webp +0 -0
  228. package/website/assets/yakmesh32.png +0 -0
  229. package/website/assets/yakmesh32.svg +0 -65
  230. package/website/assets/yakmesh32o.ico +0 -2
  231. package/website/assets/yakmesh32o.svg +0 -65
  232. package/website/assets/yakmesh32o.svgz +0 -0
package/mesh/network.js CHANGED
@@ -16,16 +16,41 @@
16
16
  * ║ PROTOCOL PHILOSOPHY: ║
17
17
  * ║ "Sacred geometry binds us" - Structure creates resilience ║
18
18
  * ║ ║
19
+ * ║ SECURITY POLICY (2026-02-11): ║
20
+ * ║ ALL peer-to-peer communications MUST use ANNEX encryption. ║
21
+ * ║ - ML-KEM-768 key exchange on connection ║
22
+ * ║ - AES-256-GCM for message encryption ║
23
+ * ║ - No plaintext on wire between nodes ║
24
+ * ║ ║
19
25
  * ╚═══════════════════════════════════════════════════════════════════════════════╝
20
26
  *
21
27
  * MANDALA Mesh Protocol
22
28
  * WebSocket-based peer-to-peer communication forming sacred network geometry
29
+ * Encrypted via ANNEX (Autonomous Network Negotiated Encrypted eXchange)
23
30
  */
24
31
 
25
32
  import { WebSocketServer, WebSocket } from 'ws';
33
+ import { networkInterfaces } from 'os';
26
34
  import { ConnectionRateLimiter } from './rate-limiter.js';
27
35
  import { createLogger } from '../utils/logger.js';
28
36
 
37
+ // ANNEX - Autonomous Network Negotiated Encrypted eXchange
38
+ // PQ-encrypted point-to-point communication between mesh peers
39
+ import { Annex } from './annex.js';
40
+
41
+ // JHILKE — Just Hidden In-band Legitimate Key Exchange (झिल्के — cricket chirps)
42
+ // Deterministic bootstrap + steganographic rekey coordination
43
+ import { JhilkeCoordinator } from './jhilke.js';
44
+
45
+ // MessageValidator + SafeJsonParser — size limits, depth checks, proto pollution guard
46
+ import MessageValidator, { SafeJsonParser } from './message-validator.js';
47
+
48
+ // TRIBHUJ Key Ratchet — trinary rotating keypairs with gateway attestation
49
+ import { TribhujRatchet, GatewayAttestation } from '../identity/tribhuj-ratchet.js';
50
+
51
+ /** Extract unique peer suffix from nodeId (e.g. 'node-net-name-pq-kEEU' → 'kEEU') */
52
+ const peerTag = (id) => id?.split('-pq-').pop() || id?.slice?.(-8) || String(id);
53
+
29
54
  const log = createLogger('mandala:network');
30
55
 
31
56
  /**
@@ -63,9 +88,12 @@ export class MandalaNetwork {
63
88
  this.identity = identity;
64
89
  this.config = {
65
90
  wsPort: config.wsPort || 9001,
66
- maxPeers: config.maxPeers || 10,
67
91
  pingInterval: config.pingInterval || 30000,
68
92
  portRetries: config.portRetries || 10, // Try up to 10 sequential ports
93
+ // Max peers allowed in HELLO/WELCOME handshake simultaneously.
94
+ // Total connected peers is UNBOUNDED — the mesh scales freely.
95
+ // This only gates the handshake window to prevent Sybil flood attacks.
96
+ maxConcurrentHandshakes: config.maxConcurrentHandshakes || 50,
69
97
  ...config,
70
98
  };
71
99
 
@@ -76,15 +104,55 @@ export class MandalaNetwork {
76
104
  this.networkId = config.networkId || null;
77
105
  this.networkFingerprint = config.networkFingerprint || null;
78
106
 
107
+ // Oracle code hash for JHILKE bootstrap key derivation
108
+ this.codeHash = config.codeHash || null;
109
+
79
110
  this.server = null;
80
111
  this.peers = new Map(); // nodeId -> { ws, identity, lastSeen }
81
112
  this.knownNodes = new Map(); // nodeId -> { endpoint, identity }
82
113
  this.messageHandlers = new Map();
83
114
  this.seenMessages = new Set(); // For gossip deduplication
84
115
 
116
+ // ANNEX - PQ-encrypted point-to-point channels
117
+ // Initialized after start() when identity is available
118
+ this.annex = null;
119
+
120
+ // TRIBHUJ ratchet - trinary rotating keypairs for forward secrecy
121
+ this.ratchet = null;
122
+ this.gateway = null; // Gateway attestation for gossip verify-once
123
+
124
+ // Track peer ratchet states (their announced TRIBHUJ public keys)
125
+ this.peerRatchets = new Map(); // nodeId -> { currentPubKey, previousPubKey, epoch }
126
+
85
127
  // Rate limiter for connection/message flood protection
86
128
  this.rateLimiter = new ConnectionRateLimiter(config.rateLimiter || {});
87
129
 
130
+ // Concurrent handshake tracking — limits how many peers can be in the
131
+ // HELLO/WELCOME negotiation window at the same time. Legitimate nodes
132
+ // trickle in; a burst of 200 simultaneous connections is a Sybil tell.
133
+ // Total peer count is UNBOUNDED (mesh scales freely).
134
+ this._pendingHandshakeCount = 0;
135
+ this._pendingHandshakeWs = new Set(); // Track WSs in handshake state
136
+
137
+ // Connection burst detector — sliding window for GPS-timestamped alerts.
138
+ // A sudden spike from baseline to hundreds of connections per minute
139
+ // shows up as a "bright spot" with microsecond-precise timing evidence.
140
+ this._burstWindow = []; // [{ ts, ip }] — last 60s of connections
141
+ this._burstWindowMs = 60000; // 60-second sliding window
142
+ this._burstThreshold = 30; // connections/minute that trigger alert
143
+ this._burstAlerted = false; // debounce: one alert per burst episode
144
+ this._burstStats = {
145
+ totalBurstsDetected: 0,
146
+ lastBurstAt: null,
147
+ lastBurstRate: 0,
148
+ peakRate: 0,
149
+ };
150
+
151
+ // Message validation — size limits, depth limits, proto pollution guard
152
+ // This was implemented but never wired in. Now it gates ALL incoming WS messages.
153
+ this.messageValidator = new MessageValidator();
154
+ this.safeJsonParser = new SafeJsonParser();
155
+
88
156
  this._setupDefaultHandlers();
89
157
  }
90
158
 
@@ -104,6 +172,64 @@ export class MandalaNetwork {
104
172
  log.warn('Port was in use, bound to alternate', { originalPort: basePort, boundPort: port });
105
173
  }
106
174
  log.info('Mesh server listening', { url: `ws://localhost:${port}` });
175
+
176
+ // Initialize ANNEX encryption layer
177
+ this.annex = new Annex({ identity: this.identity, mesh: this });
178
+
179
+ // CRITICAL: Route decrypted ANNEX payloads back to mesh handlers.
180
+ // Without this, messages encrypted by _send() via ANNEX are decrypted
181
+ // but never dispatched to GOSSIP/PING/PONG handlers — they vanish.
182
+ this.annex.onMessage(async (msg) => {
183
+ const payload = msg.payload;
184
+ if (!payload || typeof payload !== 'object') return;
185
+
186
+ const msgType = payload.type || 'gossip';
187
+ const handlers = this.messageHandlers.get(msgType) || [];
188
+ if (handlers.length === 0) return;
189
+
190
+ // Find the WS for this peer (needed by PING handler etc.)
191
+ const peer = this.peers.get(msg.from);
192
+ if (!peer) return; // No peer = stale ANNEX session, skip
193
+
194
+ for (const handler of handlers) {
195
+ try {
196
+ handler(payload, peer.ws, msg.from);
197
+ } catch (err) {
198
+ log.warn('ANNEX→mesh handler error', { type: msgType, error: err.message });
199
+ }
200
+ }
201
+ });
202
+ log.info('ANNEX encryption layer initialized');
203
+
204
+ // Initialize JHILKE coordinator (bootstrap + steganographic rekey)
205
+ if (this.codeHash) {
206
+ this.jhilke = new JhilkeCoordinator({
207
+ codeHash: this.codeHash,
208
+ nodeId: this.identity.identity.nodeId,
209
+ annex: this.annex,
210
+ mesh: this,
211
+ });
212
+ this.annex.jhilke = this.jhilke; // Cross-reference for rekey routing
213
+ this.jhilke.start(); // Start 1s cricket tick loop
214
+ log.info('JHILKE coordinator initialized (cricket chorus active)');
215
+ }
216
+
217
+ // Initialize TRIBHUJ key ratchet — trinary rotating keypairs
218
+ this.ratchet = new TribhujRatchet({
219
+ rotationInterval: this.config.tribhujRotation || 300000, // 5min default
220
+ gracePeriod: this.config.tribhujGrace || 60000, // 1min grace
221
+ });
222
+ await this.ratchet.initialize();
223
+ this.ratchet.startAutoRotation();
224
+
225
+ // Gateway attestation — verify gossip once, attest for downstream
226
+ this.gateway = new GatewayAttestation(
227
+ this.identity.identity.nodeId,
228
+ this.ratchet,
229
+ { attestationTTL: 60000 }
230
+ );
231
+ log.info('TRIBHUJ ratchet + gateway attestation initialized');
232
+
107
233
  this._startPingLoop();
108
234
  return;
109
235
  } catch (err) {
@@ -122,7 +248,7 @@ export class MandalaNetwork {
122
248
  */
123
249
  _tryBindPort(port) {
124
250
  return new Promise((resolve, reject) => {
125
- const server = new WebSocketServer({ port });
251
+ const server = new WebSocketServer({ port, maxPayload: 1048576 }); // 1MB max message size
126
252
 
127
253
  server.on('listening', () => {
128
254
  this.server = server;
@@ -150,11 +276,14 @@ export class MandalaNetwork {
150
276
  async connect(endpoint) {
151
277
  return new Promise((resolve, reject) => {
152
278
  log.debug('Connecting to peer', { endpoint });
279
+ let settled = false;
153
280
 
154
281
  const ws = new WebSocket(endpoint);
282
+ ws._outboundEndpoint = endpoint; // Track origin for reconnect detection
155
283
 
156
284
  ws.on('open', () => {
157
285
  // Send HELLO with our identity AND network fingerprint for code proof verification
286
+ // Include our advertised endpoint so inbound peers know how to reach us
158
287
  this._send(ws, {
159
288
  type: MessageTypes.HELLO,
160
289
  identity: {
@@ -162,6 +291,7 @@ export class MandalaNetwork {
162
291
  networkId: this.networkId,
163
292
  networkFingerprint: this.networkFingerprint,
164
293
  },
294
+ advertisedEndpoint: this._getAdvertisedEndpoint(),
165
295
  timestamp: Date.now(),
166
296
  });
167
297
  });
@@ -175,13 +305,19 @@ export class MandalaNetwork {
175
305
  });
176
306
 
177
307
  ws.on('error', (err) => {
178
- console.error(`Connection to ${endpoint} failed:`, err.message);
179
- reject(err);
308
+ if (!settled) {
309
+ settled = true;
310
+ log.debug(`Connection to ${endpoint} failed: ${err.message}`);
311
+ reject(err);
312
+ }
313
+ // If already settled (e.g. caller timed out), just silently close
314
+ try { ws.close(); } catch {}
180
315
  });
181
316
 
182
317
  // Resolve when we get WELCOME back
183
318
  const welcomeHandler = (msg) => {
184
- if (msg.type === MessageTypes.WELCOME) {
319
+ if (msg.type === MessageTypes.WELCOME && !settled) {
320
+ settled = true;
185
321
  log.info('Connected to peer', { nodeId: msg.identity.nodeId });
186
322
  resolve(msg.identity);
187
323
  }
@@ -190,6 +326,31 @@ export class MandalaNetwork {
190
326
  });
191
327
  }
192
328
 
329
+ /**
330
+ * Send encrypted message to specific peer via ANNEX.
331
+ * HARD FAIL: If no ANNEX session exists, the message is NOT sent.
332
+ * Caller must handle the error and initiate ANNEX negotiation.
333
+ */
334
+ async sendEncrypted(nodeId, payload) {
335
+ if (this.annex) {
336
+ const session = this.annex.sessions.get(nodeId);
337
+ if (session?.established && !session.isExpired()) {
338
+ return await this.annex.send(nodeId, payload);
339
+ }
340
+ }
341
+ // HARD FAIL: No plaintext fallback. Encryption is mandatory.
342
+ const err = new Error(`No active ANNEX session for ${peerTag(nodeId)} — refusing plaintext send`);
343
+ log.error(err.message);
344
+ throw err;
345
+ }
346
+
347
+ /**
348
+ * Get ANNEX encryption stats
349
+ */
350
+ getAnnexStats() {
351
+ return this.annex?.getStats() || { activeSessions: 0, note: 'ANNEX not initialized' };
352
+ }
353
+
193
354
  /**
194
355
  * Broadcast message to all peers (gossip)
195
356
  */
@@ -205,32 +366,38 @@ export class MandalaNetwork {
205
366
  timestamp: Date.now(),
206
367
  };
207
368
 
208
- // Sign the message
209
- const signed = this.identity.signObject(gossipMsg);
369
+ // Sign the message — prefer TRIBHUJ ratchet for forward secrecy, fall back to identity
370
+ const signed = this.ratchet
371
+ ? this.ratchet.signObject(gossipMsg)
372
+ : this.identity.signObject(gossipMsg);
210
373
 
211
374
  this.seenMessages.add(msgId);
212
375
 
213
- // Send to all peers
376
+ // Send to all WS peers
214
377
  for (const [nodeId, peer] of this.peers) {
215
378
  this._send(peer.ws, signed);
216
379
  }
380
+
381
+ // Emit for HTTP relay peers (server layer hooks this)
382
+ this.emit('outbound-gossip', signed, []);
217
383
  }
218
384
 
219
385
  /**
220
- * Send message to specific peer
386
+ * Send message to specific peer (WS or relay fallback)
221
387
  */
222
388
  sendTo(nodeId, message) {
389
+ const signed = this.ratchet
390
+ ? this.ratchet.signObject({ ...message, timestamp: Date.now() })
391
+ : this.identity.signObject({ ...message, timestamp: Date.now() });
392
+
223
393
  const peer = this.peers.get(nodeId);
224
- if (!peer) {
225
- throw new Error(`Peer ${nodeId} not connected`);
394
+ if (peer) {
395
+ this._send(peer.ws, signed);
396
+ return;
226
397
  }
227
-
228
- const signed = this.identity.signObject({
229
- ...message,
230
- timestamp: Date.now(),
231
- });
232
-
233
- this._send(peer.ws, signed);
398
+
399
+ // Not a WS peer — try relay fallback (server layer hooks this)
400
+ this.emit('outbound-relay', nodeId, signed);
234
401
  }
235
402
 
236
403
  /**
@@ -299,10 +466,74 @@ export class MandalaNetwork {
299
466
  }));
300
467
  }
301
468
 
469
+ /**
470
+ * Get our advertised WebSocket endpoint for peer discovery.
471
+ * This tells inbound peers how to reconnect to us.
472
+ */
473
+ _getAdvertisedEndpoint() {
474
+ if (!this.boundPort) return null;
475
+
476
+ // Use configured advertise address if set (for NAT/proxy scenarios)
477
+ if (this.config.advertiseAddress) {
478
+ return this.config.advertiseAddress;
479
+ }
480
+
481
+ // Otherwise construct from best-guess local IP + bound port
482
+ // Prefer non-localhost addresses for LAN/WAN connectivity
483
+ const ifaces = networkInterfaces();
484
+ let bestIp = '127.0.0.1';
485
+
486
+ for (const [name, addrs] of Object.entries(ifaces)) {
487
+ for (const addr of addrs) {
488
+ if (addr.family === 'IPv4' && !addr.internal) {
489
+ // Prefer 192.168.x.x or 10.x.x.x (private networks)
490
+ if (addr.address.startsWith('192.168.') || addr.address.startsWith('10.')) {
491
+ bestIp = addr.address;
492
+ break;
493
+ }
494
+ // Fallback to any non-internal IPv4
495
+ if (bestIp === '127.0.0.1') {
496
+ bestIp = addr.address;
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ return `ws://${bestIp}:${this.boundPort}`;
503
+ }
504
+
302
505
  /**
303
506
  * Stop the mesh server
304
507
  */
305
508
  async stop() {
509
+ // Stop ping loop
510
+ if (this._pingInterval) {
511
+ clearInterval(this._pingInterval);
512
+ this._pingInterval = null;
513
+ }
514
+
515
+ // Close all ANNEX channels
516
+ if (this.annex) {
517
+ for (const nodeId of this.annex.sessions.keys()) {
518
+ try { await this.annex.closeChannel(nodeId); } catch {}
519
+ }
520
+ this.annex = null;
521
+ }
522
+
523
+ // Stop JHILKE coordinator
524
+ if (this.jhilke) {
525
+ this.jhilke.stop();
526
+ this.jhilke = null;
527
+ }
528
+
529
+ // Destroy TRIBHUJ ratchet — zero all key material
530
+ if (this.ratchet) {
531
+ this.ratchet.destroy();
532
+ this.ratchet = null;
533
+ }
534
+ this.gateway = null;
535
+ this.peerRatchets.clear();
536
+
306
537
  // Close all peer connections
307
538
  for (const [nodeId, peer] of this.peers) {
308
539
  peer.ws.close();
@@ -329,7 +560,7 @@ export class MandalaNetwork {
329
560
  // Nodes with different codebases will have different fingerprints
330
561
  if (this.networkFingerprint && msg.identity.networkFingerprint) {
331
562
  if (msg.identity.networkFingerprint !== this.networkFingerprint) {
332
- console.warn(`✗ Rejected peer ${nodeId.slice(0, 20)}... - incompatible codebase`);
563
+ console.warn(`✗ Rejected peer ${peerTag(nodeId)} - incompatible codebase`);
333
564
  console.warn(` Their network: ${msg.identity.networkId || 'unknown'}`);
334
565
  console.warn(` Our network: ${this.networkId || 'unknown'}`);
335
566
 
@@ -345,14 +576,53 @@ export class MandalaNetwork {
345
576
  }
346
577
  }
347
578
 
348
- // Store peer
579
+ // DUPLICATE / RECONNECT DETECTION: If this peer is already connected
580
+ // with a different WebSocket, decide which connection to keep.
581
+ const existingPeer = this.peers.get(nodeId);
582
+ if (existingPeer && existingPeer.ws !== ws) {
583
+ const oldAlive = existingPeer.ws.readyState === WebSocket.OPEN;
584
+ if (oldAlive) {
585
+ // Existing connection is still alive — this is a duplicate, not a
586
+ // reconnect. Close the NEW socket to avoid ping-pong overwrites.
587
+ log.info('Duplicate connection from peer — keeping existing WS', { peer: peerTag(nodeId) });
588
+ try { ws.close(1000, 'Duplicate connection'); } catch {}
589
+ return;
590
+ }
591
+ // Old WS is dead — genuine reconnect. Reset ANNEX state.
592
+ log.info('Peer reconnected (new WS) — resetting ANNEX/JHILKE state', { peer: peerTag(nodeId) });
593
+ if (this.annex) {
594
+ this.annex.sessions.delete(nodeId);
595
+ this.annex.pendingHandshakes.delete(nodeId);
596
+ }
597
+ if (this.jhilke) {
598
+ this.jhilke.removePeer(nodeId);
599
+ }
600
+ try { existingPeer.ws.close(1000, 'Replaced by reconnect'); } catch {}
601
+ }
602
+
603
+ // Store peer — no cap on total peers (mesh scales freely)
604
+ // For outbound connections, use our tracked endpoint.
605
+ // For inbound connections, use peer's advertised endpoint (so we can reconnect to them).
606
+ const peerEndpoint = ws._outboundEndpoint || msg.advertisedEndpoint || null;
349
607
  this.peers.set(nodeId, {
350
608
  ws,
351
609
  identity: msg.identity,
610
+ endpoint: peerEndpoint,
352
611
  lastSeen: Date.now(),
353
612
  });
354
613
 
355
- // Send WELCOME back with our network info
614
+ if (peerEndpoint && !ws._outboundEndpoint) {
615
+ log.debug('Learned peer endpoint from inbound connection', { peer: peerTag(nodeId), endpoint: peerEndpoint });
616
+ }
617
+
618
+ // Release handshake slot — peer is now fully registered.
619
+ // The slot was reserved in _handleIncomingConnection.
620
+ if (this._pendingHandshakeWs.has(ws)) {
621
+ this._pendingHandshakeCount = Math.max(0, this._pendingHandshakeCount - 1);
622
+ this._pendingHandshakeWs.delete(ws);
623
+ }
624
+
625
+ // Send WELCOME back with our network info + our advertised endpoint
356
626
  this._send(ws, {
357
627
  type: MessageTypes.WELCOME,
358
628
  identity: {
@@ -360,10 +630,49 @@ export class MandalaNetwork {
360
630
  networkId: this.networkId,
361
631
  networkFingerprint: this.networkFingerprint,
362
632
  },
633
+ advertisedEndpoint: this._getAdvertisedEndpoint(),
363
634
  peers: this.getPeers().filter(p => p.nodeId !== nodeId),
364
635
  });
365
636
 
366
- log.info('Peer connected', { name: msg.identity.name, nodeId: nodeId.slice(0, 20) });
637
+ log.info('Peer connected', { name: msg.identity.name, peer: peerTag(nodeId), totalPeers: this.peers.size });
638
+
639
+ // Signal that this peer's public key is now available — any deferred
640
+ // ANNEX messages waiting for this key will be replayed.
641
+ this.emit('peer-registered', nodeId);
642
+
643
+ // Deterministic initiator: lower nodeId always initiates ANNEX
644
+ // Prevents duplicate sessions when both sides try to openChannel simultaneously
645
+ // Guard: skip if the WELCOME handler already initiated (both fire when
646
+ // two nodes simultaneously connect to each other as bootstrap peers)
647
+ const ourNodeId = this.identity.identity.nodeId;
648
+ if (this.annex && ourNodeId < nodeId) {
649
+ const existingSession = this.annex.sessions.get(nodeId);
650
+ const pendingHandshake = this.annex.pendingHandshakes.get(nodeId);
651
+ if (!existingSession && !pendingHandshake) {
652
+ // JHILKE: Bootstrap session with deterministic key BEFORE KEM exchange
653
+ // Both nodes derive the same key from shared code hash + node IDs.
654
+ // This means traffic is encrypted from message #1 — no plaintext KEM.
655
+ if (this.jhilke) {
656
+ const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
657
+ this.annex.bootstrapSession(nodeId, bootstrapKey);
658
+ }
659
+
660
+ log.debug('ANNEX: we initiate (lower nodeId)', { us: peerTag(ourNodeId), them: peerTag(nodeId) });
661
+ this.annex.openChannel(nodeId).then(() => {
662
+ log.info('ANNEX channel established with peer', { peerId: peerTag(nodeId) });
663
+ }).catch(err => {
664
+ log.warn('ANNEX negotiation failed', { peerId: peerTag(nodeId), error: err.message });
665
+ });
666
+ }
667
+ } else if (this.annex) {
668
+ // JHILKE: Responder also derives bootstrap key (same deterministic key)
669
+ // so it can decrypt the incoming KEM exchange from the initiator
670
+ if (this.jhilke && !this.annex.sessions.get(nodeId)) {
671
+ const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
672
+ this.annex.bootstrapSession(nodeId, bootstrapKey);
673
+ }
674
+ log.debug('ANNEX: waiting for peer to initiate (they have lower nodeId)', { us: peerTag(ourNodeId), them: peerTag(nodeId) });
675
+ }
367
676
  });
368
677
 
369
678
  // Handle WELCOME
@@ -374,7 +683,7 @@ export class MandalaNetwork {
374
683
  // This protects the INITIATOR - even if remote accepts us, we reject them if mismatched
375
684
  if (this.networkFingerprint && msg.identity.networkFingerprint) {
376
685
  if (msg.identity.networkFingerprint !== this.networkFingerprint) {
377
- console.warn(`✗ Rejecting peer ${nodeId.slice(0, 20)}... - incompatible codebase (on WELCOME)`);
686
+ console.warn(`✗ Rejecting peer ${peerTag(nodeId)} - incompatible codebase (on WELCOME)`);
378
687
  console.warn(` Their network: ${msg.identity.networkId || 'unknown'}`);
379
688
  console.warn(` Our network: ${this.networkId || 'unknown'}`);
380
689
  ws.close(1008, 'Incompatible codebase');
@@ -388,7 +697,7 @@ export class MandalaNetwork {
388
697
  }
389
698
  } else if (this.networkFingerprint && !msg.identity.networkFingerprint) {
390
699
  // Remote node didn't send fingerprint - they're running old code
391
- console.warn(`✗ Rejecting peer ${nodeId.slice(0, 20)}... - no fingerprint (old codebase)`);
700
+ console.warn(`✗ Rejecting peer ${peerTag(nodeId)} - no fingerprint (old codebase)`);
392
701
  ws.close(1008, 'Missing network fingerprint');
393
702
 
394
703
  if (ws._pendingWelcome) {
@@ -398,17 +707,114 @@ export class MandalaNetwork {
398
707
  return;
399
708
  }
400
709
 
710
+ // DUPLICATE / RECONNECT DETECTION (WELCOME path): same logic as HELLO.
711
+ const existingPeerW = this.peers.get(nodeId);
712
+ if (existingPeerW && existingPeerW.ws !== ws) {
713
+ const oldAlive = existingPeerW.ws.readyState === WebSocket.OPEN;
714
+ if (oldAlive) {
715
+ // Existing connection is still alive — duplicate. Keep the old one.
716
+ // Tag the existing peer with this endpoint so bootstrap's
717
+ // connectedEndpoints check will match and stop retrying.
718
+ if (ws._outboundEndpoint && !existingPeerW.endpoint) {
719
+ log.info('Updating peer endpoint from duplicate outbound', {
720
+ peer: peerTag(nodeId),
721
+ newEndpoint: ws._outboundEndpoint
722
+ });
723
+ existingPeerW.endpoint = ws._outboundEndpoint;
724
+ }
725
+ log.info('Duplicate outbound to peer — keeping existing WS', { peer: peerTag(nodeId) });
726
+ try { ws.close(1000, 'Duplicate connection'); } catch {}
727
+ // Still resolve the pending promise so bootstrap doesn't retry
728
+ if (ws._pendingWelcome) {
729
+ ws._pendingWelcome(msg);
730
+ delete ws._pendingWelcome;
731
+ }
732
+ return;
733
+ }
734
+ // Old WS is dead — genuine reconnect. Reset ANNEX state.
735
+ log.info('Peer reconnected on WELCOME (new WS) — resetting ANNEX/JHILKE state', { peer: peerTag(nodeId) });
736
+ if (this.annex) {
737
+ this.annex.sessions.delete(nodeId);
738
+ this.annex.pendingHandshakes.delete(nodeId);
739
+ }
740
+ if (this.jhilke) {
741
+ this.jhilke.removePeer(nodeId);
742
+ }
743
+ try { existingPeerW.ws.close(1000, 'Replaced by reconnect'); } catch {}
744
+ }
745
+
746
+ // Store peer — for outbound we have _outboundEndpoint, for inbound use advertised
747
+ const peerEndpoint = ws._outboundEndpoint || msg.advertisedEndpoint || null;
401
748
  this.peers.set(nodeId, {
402
749
  ws,
403
750
  identity: msg.identity,
751
+ endpoint: peerEndpoint,
404
752
  lastSeen: Date.now(),
405
753
  });
406
754
 
755
+ if (peerEndpoint && !ws._outboundEndpoint) {
756
+ log.debug('Learned peer endpoint from WELCOME', { peer: peerTag(nodeId), endpoint: peerEndpoint });
757
+ }
758
+
407
759
  // Callback for pending connection
408
760
  if (ws._pendingWelcome) {
409
761
  ws._pendingWelcome(msg);
410
762
  delete ws._pendingWelcome;
411
763
  }
764
+
765
+ // Signal that this peer's public key is now available
766
+ this.emit('peer-registered', nodeId);
767
+
768
+ // Deterministic initiator: connector side also checks
769
+ // Lower nodeId always initiates ANNEX — mirrors the HELLO handler logic
770
+ // Guard: skip if the HELLO handler already initiated (openChannel returns
771
+ // the existing session when one is pending, but we avoid the extra call entirely)
772
+ const ourNodeId = this.identity.identity.nodeId;
773
+ if (this.annex && ourNodeId < nodeId) {
774
+ const existingSession = this.annex.sessions.get(nodeId);
775
+ const pendingHandshake = this.annex.pendingHandshakes.get(nodeId);
776
+ if (!existingSession && !pendingHandshake) {
777
+ // JHILKE: Bootstrap session with deterministic key
778
+ if (this.jhilke) {
779
+ const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
780
+ this.annex.bootstrapSession(nodeId, bootstrapKey);
781
+ }
782
+
783
+ log.debug('ANNEX: we initiate on WELCOME (lower nodeId)', { us: peerTag(ourNodeId), them: peerTag(nodeId) });
784
+ this.annex.openChannel(nodeId).then(() => {
785
+ log.info('ANNEX channel established with peer', { peerId: peerTag(nodeId) });
786
+ }).catch(err => {
787
+ log.warn('ANNEX negotiation failed', { peerId: peerTag(nodeId), error: err.message });
788
+ });
789
+ }
790
+ } else if (this.annex) {
791
+ // JHILKE: Responder derives bootstrap key on WELCOME too
792
+ if (this.jhilke && !this.annex.sessions.get(nodeId)) {
793
+ const bootstrapKey = this.jhilke.deriveBootstrapKey(nodeId);
794
+ this.annex.bootstrapSession(nodeId, bootstrapKey);
795
+ }
796
+ }
797
+ });
798
+
799
+ // Handle REJECT — peer rejected our connection (incompatible codebase, etc.)
800
+ this.on('REJECT', (msg, ws) => {
801
+ log.warn('Connection rejected by peer', {
802
+ reason: msg.reason || 'unknown',
803
+ theirNetwork: msg.ourNetworkId || 'unknown',
804
+ });
805
+ // Signal rejection to pending promise if this was an outbound connection
806
+ if (ws._pendingWelcome) {
807
+ ws._pendingWelcome({ rejected: true, reason: msg.reason });
808
+ delete ws._pendingWelcome;
809
+ }
810
+ try { ws.close(1000, 'Rejected'); } catch {}
811
+ });
812
+
813
+ // Handle mesh_entropy — JHILKE cricket signals hidden in entropy exchange
814
+ this.on('mesh_entropy', (msg, ws, senderNodeId) => {
815
+ if (this.jhilke && senderNodeId) {
816
+ this.jhilke.handleIncoming(senderNodeId, msg);
817
+ }
412
818
  });
413
819
 
414
820
  // Handle PING
@@ -427,24 +833,33 @@ export class MandalaNetwork {
427
833
  // Handle GOSSIP
428
834
  this.on(MessageTypes.GOSSIP, (msg, ws, nodeId) => {
429
835
  // Deduplicate
430
- if (this.seenMessages.has(msg.id)) return;
836
+ if (this.seenMessages.has(msg.id)) {
837
+ log.debug('GOSSIP dedup — already seen', { id: msg.id?.slice(0, 12) });
838
+ return;
839
+ }
431
840
  this.seenMessages.add(msg.id);
432
841
 
433
842
  // TTL check
434
- if (msg.ttl <= 0) return;
843
+ if (msg.ttl <= 0) {
844
+ log.debug('GOSSIP TTL expired', { id: msg.id?.slice(0, 12) });
845
+ return;
846
+ }
435
847
 
436
848
  // Check for gossip protocol message
437
849
  if (msg.payload && msg.payload.gossip) {
438
850
  this.emit('gossip', msg.payload.gossip, nodeId);
439
851
  }
440
852
 
441
- // Forward to other peers
853
+ // Forward to other WS peers
442
854
  const forwardMsg = { ...msg, ttl: msg.ttl - 1 };
443
855
  for (const [peerId, peer] of this.peers) {
444
856
  if (peerId !== nodeId && peerId !== msg.origin) {
445
857
  this._send(peer.ws, forwardMsg);
446
858
  }
447
859
  }
860
+
861
+ // Also forward to HTTP relay peers (server layer hooks this)
862
+ this.emit('outbound-gossip', forwardMsg, [nodeId, msg.origin]);
448
863
  });
449
864
  }
450
865
 
@@ -452,7 +867,7 @@ export class MandalaNetwork {
452
867
  const clientIp = req.socket.remoteAddress || 'unknown';
453
868
  log.debug('Incoming connection', { clientIp });
454
869
 
455
- // SECURITY: Rate limit check for connection flood protection
870
+ // SECURITY: Rate limit check for connection flood protection (per-IP)
456
871
  const connectionCheck = this.rateLimiter.checkConnection(clientIp);
457
872
  if (!connectionCheck.allowed) {
458
873
  console.warn(`⚠️ Connection rejected (rate limit): ${clientIp} - ${connectionCheck.reason}`);
@@ -460,11 +875,38 @@ export class MandalaNetwork {
460
875
  return;
461
876
  }
462
877
 
878
+ // SECURITY: Concurrent handshake gate — limits how many peers can be
879
+ // negotiating HELLO/WELCOME simultaneously. Total peers is unbounded;
880
+ // only the handshake window is capped. A burst of connections from
881
+ // many IPs at once is a Sybil tell.
882
+ if (this._pendingHandshakeCount >= this.config.maxConcurrentHandshakes) {
883
+ log.warn('Connection rejected (handshake slots full)', {
884
+ clientIp,
885
+ pending: this._pendingHandshakeCount,
886
+ max: this.config.maxConcurrentHandshakes,
887
+ });
888
+ ws.close(1013, 'Try again later — handshake slots full');
889
+ return;
890
+ }
891
+
892
+ // Track this connection as pending handshake
893
+ this._pendingHandshakeCount++;
894
+ this._pendingHandshakeWs.add(ws);
895
+
896
+ // SECURITY: Burst detection — track connection rate in sliding window.
897
+ // GPS-timestamped evidence for Sybil forensics.
898
+ this._recordConnectionBurst(clientIp);
899
+
463
900
  ws.on('message', (data) => {
464
901
  this._handleMessage(ws, data, req);
465
902
  });
466
903
 
467
904
  ws.on('close', () => {
905
+ // Release handshake slot if peer disconnects before completing HELLO
906
+ if (this._pendingHandshakeWs.has(ws)) {
907
+ this._pendingHandshakeCount = Math.max(0, this._pendingHandshakeCount - 1);
908
+ this._pendingHandshakeWs.delete(ws);
909
+ }
468
910
  this._handleDisconnect(ws);
469
911
  });
470
912
 
@@ -475,23 +917,135 @@ export class MandalaNetwork {
475
917
 
476
918
  _handleMessage(ws, data, req) {
477
919
  try {
478
- const msg = JSON.parse(data.toString());
920
+ const rawStr = data.toString();
921
+
922
+ // STAGE 1: Raw size validation — reject before parsing
923
+ const rawCheck = this.messageValidator.validateRaw(rawStr);
924
+ if (!rawCheck.valid) {
925
+ log.warn('Rejected oversized WS message', { reason: rawCheck.reason, size: rawStr.length });
926
+ return;
927
+ }
928
+
929
+ // STAGE 2: Safe JSON parse — proto pollution guard + size check
930
+ const parseResult = this.safeJsonParser.parse(rawStr);
931
+ if (!parseResult.success) {
932
+ log.warn('Rejected malformed WS message', { error: parseResult.error });
933
+ return;
934
+ }
935
+ const msg = parseResult.data;
936
+
937
+ // STAGE 3: Structure validation — depth, array length, required fields
938
+ const msgType = msg.type || 'gossip';
939
+ const structCheck = this.messageValidator.validateStructure(msg, msgType);
940
+ if (!structCheck.valid) {
941
+ log.warn('Rejected invalid WS message structure', { reason: structCheck.reason, type: msgType });
942
+ return;
943
+ }
479
944
 
480
945
  // Find nodeId for this connection
481
946
  let senderNodeId = null;
947
+ let senderPublicKey = null;
482
948
  for (const [nodeId, peer] of this.peers) {
483
949
  if (peer.ws === ws) {
484
950
  senderNodeId = nodeId;
951
+ senderPublicKey = peer.identity?.publicKey;
485
952
  peer.lastSeen = Date.now();
486
953
  break;
487
954
  }
488
955
  }
489
956
 
957
+ // SECURITY: Verify signatures on messages from known peers
958
+ // Priority: (1) gateway attestation (fast), (2) TRIBHUJ ratchet, (3) legacy identity
959
+
960
+ // Check for gateway attestation first — "verify once, trust the stamp"
961
+ if (msg._gwAttest && this.gateway) {
962
+ const attestResult = this.gateway.verifyAttestation(msg._gwAttest);
963
+ if (attestResult.valid) {
964
+ // Attestation valid — skip expensive ML-DSA-65 verify (~0.01ms vs ~2-5ms)
965
+ log.debug('Accepted via gateway attestation', {
966
+ type: msg.type,
967
+ gateway: peerTag(msg._gwAttest.gateway),
968
+ });
969
+ } else {
970
+ // Attestation invalid — still try full verification below
971
+ log.debug('Gateway attestation invalid, falling back to full verify', {
972
+ reason: attestResult.reason,
973
+ });
974
+ msg._gwAttest = null; // Clear bad attestation
975
+ }
976
+ }
977
+
978
+ // TRIBHUJ ratchet verification (rotating keys)
979
+ if (msg._tribhujSig && !msg._gwAttest?.hash) {
980
+ const payload = { ...msg };
981
+ delete payload._tribhujSig;
982
+ delete payload._tribhujEpoch;
983
+ delete payload._tribhujPubKey;
984
+
985
+ const result = this.ratchet
986
+ ? this.ratchet.verifyObject(msg, msg._tribhujPubKey)
987
+ : { valid: false, keyState: 'no_ratchet' };
988
+
989
+ if (!result.valid) {
990
+ log.warn('Rejected message with invalid TRIBHUJ signature', {
991
+ type: msg.type,
992
+ epoch: msg._tribhujEpoch,
993
+ keyState: result.keyState,
994
+ sender: peerTag(senderNodeId),
995
+ });
996
+ return; // Drop forged message
997
+ }
998
+
999
+ // If we're also a gateway, attest this for downstream peers
1000
+ if (this.gateway && msg.type === MessageTypes.GOSSIP && msg.id) {
1001
+ msg._gwAttest = this.gateway.attest(msg.id, msg.origin || senderNodeId);
1002
+ }
1003
+ }
1004
+ // Legacy identity verification (permanent key, no ratchet)
1005
+ else if (msg._signature && senderPublicKey && !msg._gwAttest?.hash) {
1006
+ const verified = this.identity.verifyObject(msg, senderPublicKey);
1007
+ if (!verified) {
1008
+ log.warn('Rejected message with invalid signature', {
1009
+ type: msg.type,
1010
+ signer: peerTag(msg._signer),
1011
+ sender: peerTag(senderNodeId),
1012
+ });
1013
+ return; // Drop forged message
1014
+ }
1015
+
1016
+ // Attest for downstream if we have a gateway
1017
+ if (this.gateway && msg.type === MessageTypes.GOSSIP && msg.id) {
1018
+ msg._gwAttest = this.gateway.attest(msg.id, msg.origin || senderNodeId);
1019
+ }
1020
+ } else if (msg._signature && !senderPublicKey) {
1021
+ // Signed message from unknown peer — might be HELLO/WELCOME flow
1022
+ // Allow through since the handshake handler validates identity
1023
+ log.debug('Signed message from unregistered peer, passing through', { type: msg.type });
1024
+ } else if (!msg._gwAttest && !msg._tribhujSig && !msg._signature) {
1025
+ // UNSIGNED message — only allow handshake types (HELLO/WELCOME/REJECT)
1026
+ // All other message types from known peers MUST be signed
1027
+ const HANDSHAKE_TYPES = new Set([MessageTypes.HELLO, MessageTypes.WELCOME, 'REJECT']);
1028
+ if (!HANDSHAKE_TYPES.has(msg.type)) {
1029
+ log.warn('Rejected unsigned message from peer', {
1030
+ type: msg.type,
1031
+ sender: peerTag(senderNodeId) || 'unknown',
1032
+ });
1033
+ return; // Drop unsigned non-handshake message
1034
+ }
1035
+ }
1036
+
490
1037
  // Dispatch to handlers
491
1038
  const handlers = this.messageHandlers.get(msg.type) || [];
492
1039
  for (const handler of handlers) {
493
1040
  handler(msg, ws, senderNodeId);
494
1041
  }
1042
+
1043
+ // Route ANNEX messages — extract envelope and pass correctly
1044
+ if (msg.annex && this.annex) {
1045
+ this.annex._handleAnnexMessage(msg.annex, senderNodeId).catch(err => {
1046
+ log.warn('ANNEX message handling error', { error: err.message });
1047
+ });
1048
+ }
495
1049
  } catch (e) {
496
1050
  console.error('Failed to parse message:', e.message);
497
1051
  }
@@ -501,20 +1055,59 @@ export class MandalaNetwork {
501
1055
  for (const [nodeId, peer] of this.peers) {
502
1056
  if (peer.ws === ws) {
503
1057
  log.info('Peer disconnected', { name: peer.identity.name });
1058
+ // Close ANNEX channel for departing peer
1059
+ if (this.annex) {
1060
+ this.annex.closeChannel(nodeId).catch(() => {});
1061
+ }
1062
+ // Clean up JHILKE session for departing peer
1063
+ if (this.jhilke) {
1064
+ this.jhilke.removePeer(nodeId);
1065
+ }
504
1066
  this.peers.delete(nodeId);
1067
+ // Signal so deferred ANNEX messages for this peer are cleaned up
1068
+ this.emit('peer-disconnected', nodeId);
505
1069
  break;
506
1070
  }
507
1071
  }
508
1072
  }
509
1073
 
510
1074
  _send(ws, message) {
511
- if (ws.readyState === WebSocket.OPEN) {
512
- ws.send(JSON.stringify(message));
1075
+ if (ws.readyState !== WebSocket.OPEN) return;
1076
+
1077
+ // Opportunistic ANNEX encryption: if we have an active session
1078
+ // for this peer, encrypt the message transparently.
1079
+ // This ensures gossip, broadcast, ping — ALL traffic — is encrypted on the wire.
1080
+ // SKIP for ANNEX control messages (type 'annex') to prevent infinite recursion:
1081
+ // _send → annex.send → _sendToMesh → mesh.sendTo → _send → ...
1082
+ if (this.annex && message.type !== 'annex') {
1083
+ // Reverse-lookup nodeId from ws
1084
+ for (const [nodeId, peer] of this.peers) {
1085
+ if (peer.ws === ws) {
1086
+ const session = this.annex.sessions.get(nodeId);
1087
+ if (session?.established && !session.isExpired()) {
1088
+ // Send via ANNEX (async, fire-and-forget for broadcast)
1089
+ this.annex.send(nodeId, message).catch(err => {
1090
+ // HARD FAIL: No plaintext fallback. Encryption is mandatory per Yakmesh ethos.
1091
+ // Peer must re-negotiate ANNEX session. Dropping message is safer than leaking it.
1092
+ log.error('ANNEX send failed — message dropped (no plaintext fallback)', {
1093
+ peer: peerTag(nodeId), error: err.message
1094
+ });
1095
+ });
1096
+ return;
1097
+ }
1098
+ break;
1099
+ }
1100
+ }
513
1101
  }
1102
+
1103
+ // Plaintext only for ANNEX handshake messages (type 'annex') and initial
1104
+ // HELLO/WELCOME before ANNEX is established. Once ANNEX exists for a
1105
+ // peer, ALL traffic MUST go through it.
1106
+ ws.send(JSON.stringify(message));
514
1107
  }
515
1108
 
516
1109
  _startPingLoop() {
517
- setInterval(() => {
1110
+ this._pingInterval = setInterval(() => {
518
1111
  const now = Date.now();
519
1112
  for (const [nodeId, peer] of this.peers) {
520
1113
  // Check for stale connections
@@ -527,12 +1120,109 @@ export class MandalaNetwork {
527
1120
  }
528
1121
  }
529
1122
 
530
- // Cleanup old seen messages
1123
+ // LRU eviction keep newest half instead of clearing all (prevents dedup bypass window)
531
1124
  if (this.seenMessages.size > 10000) {
532
- this.seenMessages.clear();
1125
+ const entries = [...this.seenMessages];
1126
+ const keepCount = Math.floor(entries.length / 2);
1127
+ this.seenMessages = new Set(entries.slice(entries.length - keepCount));
533
1128
  }
534
1129
  }, this.config.pingInterval);
535
1130
  }
1131
+
1132
+ /**
1133
+ * Record a connection in the burst detection sliding window.
1134
+ * When connections/minute exceeds _burstThreshold, emits a GPS-timestamped
1135
+ * alert — the "bright spot on the map" that makes Sybil floods visible.
1136
+ * @param {string} ip - Client IP address
1137
+ */
1138
+ _recordConnectionBurst(ip) {
1139
+ const now = Date.now();
1140
+
1141
+ // Add to sliding window
1142
+ this._burstWindow.push({ ts: now, ip });
1143
+
1144
+ // Evict entries older than window
1145
+ const cutoff = now - this._burstWindowMs;
1146
+ while (this._burstWindow.length > 0 && this._burstWindow[0].ts < cutoff) {
1147
+ this._burstWindow.shift();
1148
+ }
1149
+
1150
+ const rate = this._burstWindow.length; // connections in last 60s
1151
+
1152
+ // Track peak
1153
+ if (rate > this._burstStats.peakRate) {
1154
+ this._burstStats.peakRate = rate;
1155
+ }
1156
+
1157
+ if (rate >= this._burstThreshold && !this._burstAlerted) {
1158
+ // Count unique IPs in burst
1159
+ const uniqueIps = new Set(this._burstWindow.map(e => e.ip)).size;
1160
+
1161
+ this._burstAlerted = true;
1162
+ this._burstStats.totalBurstsDetected++;
1163
+ this._burstStats.lastBurstAt = new Date().toISOString();
1164
+ this._burstStats.lastBurstRate = rate;
1165
+
1166
+ console.warn(`🛰️ BURST DETECTED: ${rate} connections/min (threshold: ${this._burstThreshold}) from ${uniqueIps} unique IPs`);
1167
+ log.warn('Connection burst detected — possible Sybil flood', {
1168
+ connectionsPerMinute: rate,
1169
+ threshold: this._burstThreshold,
1170
+ uniqueIps,
1171
+ pendingHandshakes: this._pendingHandshakeCount,
1172
+ totalPeers: this.peers.size,
1173
+ // GPS-precision timestamp for forensic evidence
1174
+ gpsTimestamp: new Date().toISOString(),
1175
+ // IP frequency distribution (top 5 offenders)
1176
+ topIps: this._getTopBurstIps(5),
1177
+ });
1178
+
1179
+ // Emit event for external consumers (health endpoint, SAKSHI anomaly detection)
1180
+ this.emit('connection-burst', {
1181
+ rate,
1182
+ uniqueIps,
1183
+ topIps: this._getTopBurstIps(5),
1184
+ timestamp: new Date().toISOString(),
1185
+ });
1186
+
1187
+ // Reset alert after 30s (allow re-triggering if burst continues)
1188
+ setTimeout(() => { this._burstAlerted = false; }, 30000);
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Get the top N most frequent IPs in the current burst window.
1194
+ * @param {number} n - Number of top IPs to return
1195
+ * @returns {Array<{ip: string, count: number}>}
1196
+ */
1197
+ _getTopBurstIps(n = 5) {
1198
+ const counts = new Map();
1199
+ for (const entry of this._burstWindow) {
1200
+ counts.set(entry.ip, (counts.get(entry.ip) || 0) + 1);
1201
+ }
1202
+ return [...counts.entries()]
1203
+ .sort((a, b) => b[1] - a[1])
1204
+ .slice(0, n)
1205
+ .map(([ip, count]) => ({ ip, count }));
1206
+ }
1207
+
1208
+ /**
1209
+ * Security stats for /health endpoint exposure.
1210
+ * Provides visibility into handshake pressure and burst detection.
1211
+ */
1212
+ getSecurityStats() {
1213
+ return {
1214
+ pendingHandshakes: this._pendingHandshakeCount,
1215
+ maxConcurrentHandshakes: this.config.maxConcurrentHandshakes,
1216
+ totalConnectedPeers: this.peers.size,
1217
+ burstDetection: {
1218
+ currentRate: this._burstWindow.length,
1219
+ threshold: this._burstThreshold,
1220
+ windowMs: this._burstWindowMs,
1221
+ inBurst: this._burstAlerted,
1222
+ stats: { ...this._burstStats },
1223
+ },
1224
+ };
1225
+ }
536
1226
  }
537
1227
 
538
1228
  // ============================================================