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/annex.js CHANGED
@@ -27,10 +27,32 @@
27
27
 
28
28
  import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto';
29
29
  import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
30
- import { sha3_256 } from '@noble/hashes/sha3.js';
30
+ import { sha3_256 as _nobleSha3 } from '@noble/hashes/sha3.js';
31
31
  import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
32
32
  import { createLogger } from '../utils/logger.js';
33
33
 
34
+ // ACCEL: Hardware-accelerated crypto (native SHA3, native KEM via liboqs/AVX-512)
35
+ import { sha3_256, mlKem768Keygen, mlKem768Encapsulate, mlKem768Decapsulate } from '../utils/accel.js';
36
+
37
+ // STEADYWATCH: Quantum-hardware-validated entropy seeds (Hurwitz quaternion, IBM Quantum)
38
+ import { getHybridSeed, seedStore as steadywatchStore } from '../security/steadywatch.js';
39
+
40
+ // ═══ TRIBHUJ — Balanced ternary for channel lifecycle ═══
41
+ // POSITIVE: ESTABLISHED (secure channel active)
42
+ // NEUTRAL: NEGOTIATING (key exchange in progress)
43
+ // NEGATIVE: CLOSED (session terminated or expired)
44
+ import { POSITIVE, NEUTRAL, NEGATIVE } from '../oracle/tribhuj.js';
45
+
46
+ /** ANNEX channel lifecycle states (TRIBHUJ trits) */
47
+ export const ChannelState = Object.freeze({
48
+ CLOSED: NEGATIVE, // -1: Session terminated or expired
49
+ NEGOTIATING: NEUTRAL, // 0: Key exchange in progress
50
+ ESTABLISHED: POSITIVE, // +1: Secure channel active
51
+ });
52
+
53
+ /** Extract unique peer suffix from nodeId (e.g. 'node-net-name-pq-kEEU' → 'kEEU') */
54
+ const peerTag = (id) => id?.split('-pq-').pop() || id?.slice?.(-8) || String(id);
55
+
34
56
  const log = createLogger('mesh:annex');
35
57
 
36
58
  const ANNEX_CONFIG = {
@@ -144,36 +166,77 @@ class AnnexSession {
144
166
  this.kemKeyPair = null; // Our ephemeral KEM key pair
145
167
  this.sharedSecret = null; // Derived shared secret
146
168
  this.encryptionKey = null; // Current symmetric key
169
+ this.pendingEncryptionKey = null; // Future key awaiting implicit ack (bootstrap→KEM upgrade only)
170
+ this.bootstrapped = false; // JHILKE: true if using deterministic bootstrap key (not yet KEM-backed)
171
+ this.rekeyEpoch = 0; // JHILKE: deterministic rekey epoch (incremented on each cricket-coordinated switch)
147
172
  this.sendSequence = 0; // Outbound message counter
148
- this.recvSequence = 0; // Inbound message counter
173
+ this.recvSequence = -1; // Inbound message counter (-1 so first msg seq 0 passes)
149
174
  this.messageCount = 0; // Total messages with current key
150
175
 
151
176
  // State
152
177
  this.established = false;
178
+ this.channelState = ChannelState.NEGOTIATING; // TRIBHUJ trit lifecycle
153
179
  this.createdAt = Date.now();
154
180
  this.lastActivity = Date.now();
155
181
  this.lastRekey = null;
156
182
  }
157
183
 
158
184
  /**
159
- * Generate ephemeral KEM key pair for this session
185
+ * JHILKE deterministic rekey both nodes derive the same key simultaneously.
186
+ * No KEM round-trip, no pending key, no race condition.
187
+ * Called by JhilkeCoordinator._executeSwitch() after cricket coordination.
188
+ */
189
+ deterministicRekey(newKey, epoch) {
190
+ this.encryptionKey = newKey;
191
+ this.rekeyEpoch = epoch;
192
+ this.messageCount = 0;
193
+ this.lastRekey = Date.now();
194
+ this.lastActivity = Date.now();
195
+ }
196
+
197
+ /**
198
+ * Generate ephemeral KEM key pair for this session.
199
+ *
200
+ * ACCEL: Routes through mlKem768Keygen() for native liboqs AVX-512 NTT
201
+ * acceleration when available (previously called noble directly — bypassed ACCEL).
202
+ *
203
+ * STEADYWATCH: If quantum satellite seeds are loaded, uses hybrid entropy:
204
+ * hybridSeed = SHA3(satelliteSeed || EXPAND) ⊕ randomBytes(64)
205
+ * Two-source extractor: even if one source is compromised, keys are safe.
160
206
  */
161
- generateKeyPair() {
162
- const seed = randomBytes(64);
163
- this.kemKeyPair = ml_kem768.keygen(seed);
207
+ async generateKeyPair() {
208
+ // STEADYWATCH hybrid seed (quantum + CSPRNG) or pure CSPRNG fallback
209
+ const seed = steadywatchStore.initialized
210
+ ? getHybridSeed()
211
+ : randomBytes(64);
212
+
213
+ // Route through ACCEL for native liboqs/AVX-512 acceleration
214
+ this.kemKeyPair = await mlKem768Keygen(seed);
164
215
  return bytesToHex(this.kemKeyPair.publicKey);
165
216
  }
166
217
 
167
218
  /**
168
219
  * Complete key exchange as initiator (encapsulate with peer's public key)
169
220
  */
170
- encapsulate(peerPublicKey) {
221
+ encapsulate(peerPublicKey, { defer = false } = {}) {
171
222
  const publicKeyBytes = hexToBytes(peerPublicKey);
172
- const result = ml_kem768.encapsulate(publicKeyBytes);
223
+ const result = mlKem768Encapsulate(publicKeyBytes);
173
224
 
174
225
  this.sharedSecret = result.sharedSecret;
175
- this.encryptionKey = this._deriveEncryptionKey();
226
+ const newKey = this._deriveEncryptionKey();
227
+
228
+ if (defer && this.encryptionKey) {
229
+ // Rekey responder: store new key as pending, keep current active.
230
+ // PFS-safe: we only hold the FUTURE key, never the past key.
231
+ // Activation happens implicitly when we receive a message encrypted
232
+ // with the new key (see decrypt()).
233
+ this.pendingEncryptionKey = newKey;
234
+ } else {
235
+ // Initial handshake or initiator: switch immediately
236
+ this.encryptionKey = newKey;
237
+ }
176
238
  this.established = true;
239
+ this.channelState = ChannelState.ESTABLISHED;
177
240
  this.lastRekey = Date.now();
178
241
 
179
242
  return bytesToHex(result.cipherText);
@@ -188,9 +251,34 @@ class AnnexSession {
188
251
  }
189
252
 
190
253
  const ciphertextBytes = hexToBytes(ciphertext);
191
- this.sharedSecret = ml_kem768.decapsulate(ciphertextBytes, this.kemKeyPair.secretKey);
254
+ this.sharedSecret = mlKem768Decapsulate(ciphertextBytes, this.kemKeyPair.secretKey);
255
+
256
+ // Zero ephemeral KEM secret key — shared secret is extracted, secret key is
257
+ // no longer needed. Minimizes exposure window if memory is later compromised.
258
+ if (this.kemKeyPair.secretKey instanceof Uint8Array) {
259
+ this.kemKeyPair.secretKey.fill(0);
260
+ }
261
+ this.kemKeyPair = null; // Release reference entirely
262
+
263
+ // Bootstrap→KEM upgrade bridge: briefly retain old key for in-flight messages.
264
+ // The responder still encrypts with bootstrap key until implicit ack arrives.
265
+ // Without this bridge, those in-flight messages cause AEAD auth failures.
266
+ // Auto-expires after 5s — NOT a permanent "previous key" (PFS preserved).
267
+ if (this.encryptionKey) {
268
+ this._transitionKey = this.encryptionKey;
269
+ this._transitionKeyTimer = setTimeout(() => {
270
+ this._transitionKey = null;
271
+ this._transitionKeyTimer = null;
272
+ }, 5000);
273
+ }
274
+
275
+ // Initiator receiving KEY_RESPONSE: switch immediately to KEM key.
276
+ // The initiator is always "first mover" — its next message triggers
277
+ // the responder to promote pendingEncryptionKey.
192
278
  this.encryptionKey = this._deriveEncryptionKey();
279
+ this.pendingEncryptionKey = null; // Clear any pending state
193
280
  this.established = true;
281
+ this.channelState = ChannelState.ESTABLISHED;
194
282
  this.lastRekey = Date.now();
195
283
 
196
284
  return true;
@@ -237,28 +325,21 @@ class AnnexSession {
237
325
  /**
238
326
  * Decrypt a message for this session
239
327
  */
240
- decrypt(encryptedData, expectedSequence) {
241
- if (!this.established || !this.encryptionKey) {
242
- throw new Error('Session not established');
243
- }
244
-
245
- // Replay protection: sequence must be greater than last received
246
- if (expectedSequence <= this.recvSequence && this.recvSequence > 0) {
247
- throw new Error(`Replay detected: sequence ${expectedSequence} <= ${this.recvSequence}`);
248
- }
249
-
328
+ /**
329
+ * Decrypt with a specific key (internal helper)
330
+ */
331
+ _decryptWithKey(key, encryptedData, expectedSequence) {
250
332
  const nonce = Buffer.from(encryptedData.nonce, 'hex');
251
333
  const ciphertext = Buffer.from(encryptedData.ciphertext, 'hex');
252
334
  const authTag = Buffer.from(encryptedData.authTag, 'hex');
253
335
 
254
336
  const decipher = createDecipheriv(
255
337
  ANNEX_CONFIG.symmetricAlgorithm,
256
- this.encryptionKey,
338
+ key,
257
339
  nonce,
258
340
  { authTagLength: ANNEX_CONFIG.authTagLength }
259
341
  );
260
342
 
261
- // Verify AAD
262
343
  const aad = Buffer.from(`${this.sessionId}:${expectedSequence}`);
263
344
  decipher.setAAD(aad);
264
345
  decipher.setAuthTag(authTag);
@@ -268,12 +349,66 @@ class AnnexSession {
268
349
  decipher.final(),
269
350
  ]);
270
351
 
271
- this.recvSequence = expectedSequence;
272
- this.lastActivity = Date.now();
273
-
274
352
  return decrypted.toString('utf8');
275
353
  }
276
354
 
355
+ decrypt(encryptedData, expectedSequence) {
356
+ if (!this.established || !this.encryptionKey) {
357
+ throw new Error('Session not established');
358
+ }
359
+
360
+ // Replay protection: sequence must be greater than last received
361
+ if (typeof expectedSequence !== 'number' || expectedSequence <= this.recvSequence) {
362
+ throw new Error(`Replay detected: sequence ${expectedSequence} <= ${this.recvSequence}`);
363
+ }
364
+
365
+ try {
366
+ // Try current key first
367
+ const result = this._decryptWithKey(this.encryptionKey, encryptedData, expectedSequence);
368
+ this.recvSequence = expectedSequence;
369
+ this.lastActivity = Date.now();
370
+ return result;
371
+ } catch (err) {
372
+ // During rekey transition, the initiator has switched to the new key
373
+ // but the responder is still on the old key. Try the PENDING (future)
374
+ // key — if it works, promote it to current. This is the implicit ack.
375
+ //
376
+ // Security note: we only ever store the FUTURE key as fallback, never
377
+ // the PAST key. An attacker who dumps memory gets a key they'd have
378
+ // gotten anyway once activated. PFS of past messages is never at risk.
379
+ if (this.pendingEncryptionKey) {
380
+ try {
381
+ const result = this._decryptWithKey(this.pendingEncryptionKey, encryptedData, expectedSequence);
382
+ // Implicit ack: promote pending → current, zero old key
383
+ this.encryptionKey = this.pendingEncryptionKey;
384
+ this.pendingEncryptionKey = null;
385
+ this.recvSequence = expectedSequence;
386
+ this.lastActivity = Date.now();
387
+ log.info('Rekey activated via implicit ack', { sessionId: this.sessionId?.slice(0, 16) });
388
+ return result;
389
+ } catch {
390
+ // pending key also failed — fall through to transition key
391
+ }
392
+ }
393
+
394
+ // Bootstrap→KEM upgrade bridge: the responder may still send messages
395
+ // encrypted with the bootstrap key between our KEM switch and their
396
+ // implicit-ack promotion. Try the briefly-retained old key.
397
+ // NO promotion — this key is being phased out (auto-expires via timer).
398
+ if (this._transitionKey) {
399
+ const result = this._decryptWithKey(this._transitionKey, encryptedData, expectedSequence);
400
+ this.recvSequence = expectedSequence;
401
+ this.lastActivity = Date.now();
402
+ log.info('Bootstrap→KEM transition: decoded in-flight message with old key', {
403
+ sessionId: this.sessionId?.slice(0, 16),
404
+ });
405
+ return result;
406
+ }
407
+
408
+ throw err;
409
+ }
410
+ }
411
+
277
412
  /**
278
413
  * Check if session needs re-keying for perfect forward secrecy
279
414
  */
@@ -298,12 +433,15 @@ class AnnexSession {
298
433
  * Derive symmetric encryption key from shared secret
299
434
  */
300
435
  _deriveEncryptionKey() {
436
+ // Sort nodeIds so both sides derive the same key
437
+ // (localNodeId and remoteNodeId are swapped between initiator/responder)
438
+ const [first, second] = [this.localNodeId, this.remoteNodeId].sort();
301
439
  return createHash('sha3-256')
302
440
  .update(this.sharedSecret)
303
441
  .update(ANNEX_CONFIG.keyDerivationSalt)
304
442
  .update(this.sessionId)
305
- .update(this.localNodeId)
306
- .update(this.remoteNodeId)
443
+ .update(first)
444
+ .update(second)
307
445
  .digest();
308
446
  }
309
447
  }
@@ -322,6 +460,9 @@ export class Annex {
322
460
  this.pendingHandshakes = new Map();
323
461
  this.messageHandlers = new Map();
324
462
 
463
+ // JHILKE coordinator reference (set by network.js after initialization)
464
+ this.jhilke = null;
465
+
325
466
  // Stats
326
467
  this.stats = {
327
468
  sessionsCreated: 0,
@@ -331,31 +472,90 @@ export class Annex {
331
472
  replaysBlocked: 0,
332
473
  };
333
474
 
475
+ // Deferred message queue — buffer ANNEX messages from peers whose
476
+ // public key hasn't arrived yet (HELLO/WELCOME still in flight).
477
+ // Max 10 senders, 1 message per sender, 3s timeout.
478
+ this._deferredMessages = new Map(); // senderId -> { envelope, origin, timer }
479
+ this._maxDeferredSenders = 10;
480
+ this._deferTimeoutMs = 3000;
481
+
334
482
  // Register mesh handler for ANNEX messages
335
483
  if (this.mesh) {
336
484
  this._registerMeshHandlers();
337
485
  }
338
486
  }
339
487
 
488
+ /**
489
+ * Create a bootstrap session with a deterministic key.
490
+ * JHILKE derives this key from the shared code hash + both node IDs.
491
+ * Both sides compute the same key independently.
492
+ *
493
+ * The bootstrap session enables encrypted communication from message #1,
494
+ * eliminating plaintext KEM exchange. It is immediately upgraded to a
495
+ * proper KEM-backed session with full PFS.
496
+ */
497
+ bootstrapSession(peerId, bootstrapKey) {
498
+ // Deterministic sessionId — both sides MUST agree on AES-GCM AAD.
499
+ // AAD = "${sessionId}:${sequence}", so random sessionId = instant auth failure.
500
+ const localNodeId = this.identity.identity.nodeId;
501
+ const [first, second] = [localNodeId, peerId].sort();
502
+ const deterministicSessionId = createHash('sha3-256')
503
+ .update(`yakmesh-annex-bootstrap-session:${first}:${second}`)
504
+ .digest('hex')
505
+ .slice(0, 32); // same length as bytesToHex(randomBytes(16))
506
+
507
+ const session = new AnnexSession({
508
+ sessionId: deterministicSessionId,
509
+ localNodeId,
510
+ remoteNodeId: peerId,
511
+ });
512
+ session.encryptionKey = bootstrapKey;
513
+ session.established = true;
514
+ session.bootstrapped = true;
515
+ session.channelState = ChannelState.ESTABLISHED;
516
+ session.lastRekey = Date.now();
517
+
518
+ this.sessions.set(peerId, session);
519
+ log.info('JHILKE bootstrap session created (encrypted from message #1)', {
520
+ peerId: peerTag(peerId),
521
+ sessionId: deterministicSessionId.slice(0, 8) + '...',
522
+ });
523
+ return session;
524
+ }
525
+
340
526
  /**
341
527
  * Initialize or get secure session with a peer (annex territory)
528
+ *
529
+ * JHILKE integration: If a bootstrap session exists, this method
530
+ * upgrades it to a KEM-backed session by performing the key exchange
531
+ * THROUGH the bootstrap-encrypted channel (no plaintext KEM).
342
532
  */
343
533
  async openChannel(remoteNodeId) {
344
534
  // Check for existing session
345
535
  let session = this.sessions.get(remoteNodeId);
346
- if (session && session.established && !session.isExpired()) {
536
+
537
+ // Return existing FULL (non-bootstrap) session
538
+ if (session && session.established && !session.isExpired() && !session.bootstrapped) {
347
539
  return session;
348
540
  }
349
541
 
350
- // Create new session
351
- session = new AnnexSession({
352
- localNodeId: this.identity.identity.nodeId,
353
- remoteNodeId,
354
- initiator: true,
355
- });
542
+ // Bootstrap upgrade: reuse existing session, add KEM negotiation
543
+ const isBootstrapUpgrade = session?.bootstrapped;
544
+
545
+ if (isBootstrapUpgrade) {
546
+ session.initiator = true;
547
+ log.info('JHILKE: upgrading bootstrap → KEM', { peer: peerTag(remoteNodeId) });
548
+ } else {
549
+ // Create new session
550
+ session = new AnnexSession({
551
+ localNodeId: this.identity.identity.nodeId,
552
+ remoteNodeId,
553
+ initiator: true,
554
+ });
555
+ }
356
556
 
357
- // Generate our key pair
358
- const ourPublicKey = session.generateKeyPair();
557
+ // Generate our key pair (ACCEL: native liboqs/AVX-512, STEADYWATCH: quantum seed)
558
+ const ourPublicKey = await session.generateKeyPair();
359
559
 
360
560
  // Store pending handshake
361
561
  this.pendingHandshakes.set(remoteNodeId, session);
@@ -372,8 +572,8 @@ export class Annex {
372
572
  // Sign the envelope
373
573
  envelope.signature = this.identity.sign(envelope.getSigningPayload());
374
574
 
375
- // Send via mesh
376
- await this._sendToMesh(remoteNodeId, envelope);
575
+ // JHILKE: Send via secure channel if bootstrap exists, else raw
576
+ await this._sendControlSecure(remoteNodeId, envelope);
377
577
 
378
578
  // Wait for response (with timeout)
379
579
  return new Promise((resolve, reject) => {
@@ -406,9 +606,14 @@ export class Annex {
406
606
  session = await this.openChannel(remoteNodeId);
407
607
  }
408
608
 
409
- // Check for re-key need
410
- if (session.needsRekey()) {
411
- await this._rekey(session);
609
+ // Check for re-key need — JHILKE handles all rekeys deterministically.
610
+ // Both sides derive the same key after cricket coordination (no KEM round-trip).
611
+ if (!options._skipRekeyCheck && session.needsRekey()) {
612
+ if (this.jhilke) {
613
+ this.jhilke.initiateRekey(remoteNodeId);
614
+ }
615
+ // No fallback — JHILKE is the only rekey path. If unavailable,
616
+ // the session continues until timeout/reconnect.
412
617
  }
413
618
 
414
619
  // Encrypt the payload
@@ -469,7 +674,18 @@ export class Annex {
469
674
  envelope.signature = this.identity.sign(envelope.getSigningPayload());
470
675
 
471
676
  await this._sendToMesh(remoteNodeId, envelope);
677
+ session.channelState = ChannelState.CLOSED;
472
678
  this.sessions.delete(remoteNodeId);
679
+
680
+ // Clean up any deferred messages from this peer
681
+ const deferred = this._deferredMessages?.get(remoteNodeId);
682
+ if (deferred) {
683
+ clearTimeout(deferred.timer);
684
+ if (deferred.onRegistered) {
685
+ this.mesh.off('peer-registered', deferred.onRegistered);
686
+ }
687
+ this._deferredMessages.delete(remoteNodeId);
688
+ }
473
689
  }
474
690
 
475
691
  /**
@@ -482,6 +698,7 @@ export class Annex {
482
698
  return {
483
699
  sessionId: session.sessionId,
484
700
  established: session.established,
701
+ channelState: session.channelState, // TRIBHUJ trit: ESTABLISHED/NEGOTIATING/CLOSED
485
702
  createdAt: session.createdAt,
486
703
  lastActivity: session.lastActivity,
487
704
  messageCount: session.messageCount,
@@ -500,6 +717,7 @@ export class Annex {
500
717
  nodeId,
501
718
  sessionId: session.sessionId,
502
719
  established: session.established,
720
+ channelState: session.channelState, // TRIBHUJ trit
503
721
  createdAt: session.createdAt,
504
722
  lastActivity: session.lastActivity,
505
723
  messageCount: session.messageCount,
@@ -531,12 +749,21 @@ export class Annex {
531
749
 
532
750
  async _handleAnnexMessage(envelope, origin) {
533
751
  try {
534
- // Verify signature
535
- const sigPayload = AnnexEnvelope.fromJSON(envelope).getSigningPayload();
752
+ // Verify ML-DSA-65 signature — MANDATORY for all ANNEX messages.
753
+ // "Changes pass through math alone" — no key, no entry.
536
754
  const peerPublicKey = this._getPeerPublicKey(envelope.senderId);
537
755
 
538
- if (peerPublicKey && !this.identity.verify(sigPayload, envelope.signature, peerPublicKey)) {
539
- log.warn('Invalid signature from peer', { peerId: envelope.senderId?.slice(0, 16) });
756
+ if (!peerPublicKey) {
757
+ // Peer's public key isn't registered yet their HELLO/WELCOME
758
+ // may still be in flight. Defer the message and replay it once
759
+ // the mesh emits 'peer-registered' for this sender.
760
+ this._deferMessage(envelope, origin);
761
+ return;
762
+ }
763
+
764
+ const sigPayload = AnnexEnvelope.fromJSON(envelope).getSigningPayload();
765
+ if (!this.identity.verify(sigPayload, envelope.signature, peerPublicKey)) {
766
+ log.warn('Invalid ML-DSA-65 signature from peer', { peerId: peerTag(envelope.senderId) });
540
767
  return;
541
768
  }
542
769
 
@@ -553,14 +780,13 @@ export class Annex {
553
780
  await this._handleEncrypted(envelope);
554
781
  break;
555
782
 
556
- case ANNEX_CONFIG.messageTypes.REKEY:
557
- await this._handleRekey(envelope);
558
- break;
559
-
560
- case ANNEX_CONFIG.messageTypes.CLOSE:
783
+ case ANNEX_CONFIG.messageTypes.CLOSE: {
784
+ const closedSession = this.sessions.get(envelope.senderId);
785
+ if (closedSession) closedSession.channelState = ChannelState.CLOSED;
561
786
  this.sessions.delete(envelope.senderId);
562
- log.info('Channel closed by peer', { peerId: envelope.senderId?.slice(0, 16) });
787
+ log.info('Channel closed by peer', { peerId: peerTag(envelope.senderId) });
563
788
  break;
789
+ }
564
790
  }
565
791
  } catch (err) {
566
792
  log.error('Error handling ANNEX message', { error: err.message });
@@ -568,23 +794,40 @@ export class Annex {
568
794
  }
569
795
 
570
796
  async _handleKeyExchange(envelope) {
571
- log.info('Key exchange from peer', { peerId: envelope.senderId?.slice(0, 16) });
797
+ log.info('Key exchange from peer', { peerId: peerTag(envelope.senderId) });
572
798
 
573
- // Create responding session
574
- const session = new AnnexSession({
575
- sessionId: envelope.sessionId,
576
- localNodeId: this.identity.identity.nodeId,
577
- remoteNodeId: envelope.senderId,
578
- initiator: false,
579
- });
799
+ // JHILKE: Check for existing bootstrap session to upgrade
800
+ let session = this.sessions.get(envelope.senderId);
801
+ const isBootstrapUpgrade = session?.bootstrapped;
802
+
803
+ if (isBootstrapUpgrade) {
804
+ // Upgrade existing bootstrap session — keep bootstrap key as current
805
+ session.sessionId = envelope.sessionId;
806
+ session.initiator = false;
807
+ log.info('JHILKE: upgrading bootstrap → KEM (responder)', { peerId: peerTag(envelope.senderId) });
808
+ } else {
809
+ // Create responding session
810
+ session = new AnnexSession({
811
+ sessionId: envelope.sessionId,
812
+ localNodeId: this.identity.identity.nodeId,
813
+ remoteNodeId: envelope.senderId,
814
+ initiator: false,
815
+ });
816
+ }
580
817
 
581
818
  // Generate our key pair and encapsulate with peer's public key
582
- session.generateKeyPair();
583
- const kemCiphertext = session.encapsulate(envelope.kemPublicKey);
819
+ // ACCEL: native liboqs/AVX-512, STEADYWATCH: quantum seed
820
+ await session.generateKeyPair();
821
+
822
+ // CRITICAL: For bootstrap upgrades, DEFER the KEM key.
823
+ // Keep bootstrap key active for the KEY_RESPONSE message.
824
+ // The KEM key activates via implicit ack when we receive
825
+ // a message encrypted with it from the initiator.
826
+ const kemCiphertext = session.encapsulate(envelope.kemPublicKey, { defer: isBootstrapUpgrade });
584
827
 
585
828
  // Store session
586
829
  this.sessions.set(envelope.senderId, session);
587
- this.stats.sessionsCreated++;
830
+ if (!isBootstrapUpgrade) this.stats.sessionsCreated++;
588
831
 
589
832
  // Send response with our public key and the KEM ciphertext
590
833
  const response = new AnnexEnvelope({
@@ -597,27 +840,34 @@ export class Annex {
597
840
  });
598
841
 
599
842
  response.signature = this.identity.sign(response.getSigningPayload());
600
- await this._sendToMesh(envelope.senderId, response);
601
843
 
602
- log.info('Channel established with peer', { peerId: envelope.senderId?.slice(0, 16) });
844
+ // JHILKE: Send via secure channel (encrypted through bootstrap or current key)
845
+ await this._sendControlSecure(envelope.senderId, response);
846
+
847
+ log.info('Channel established with peer', { peerId: peerTag(envelope.senderId) });
603
848
  }
604
849
 
605
850
  async _handleKeyResponse(envelope) {
851
+ // KEY_RESPONSE is only used during initial handshake or bootstrap→KEM upgrade.
852
+ // All subsequent rekeys are deterministic via JHILKE (no KEM round-trip).
606
853
  const session = this.pendingHandshakes.get(envelope.senderId);
854
+
607
855
  if (!session) {
608
- log.warn('Unexpected key response', { peerId: envelope.senderId?.slice(0, 16) });
856
+ log.warn('Unexpected key response (no pending handshake)', { peerId: peerTag(envelope.senderId) });
609
857
  return;
610
858
  }
611
859
 
612
860
  // Decapsulate to get shared secret
613
861
  session.decapsulate(envelope.kemCiphertext);
614
862
 
615
- // Move to active sessions
863
+ // JHILKE: Clear bootstrap flag — now KEM-backed with full PFS
864
+ session.bootstrapped = false;
865
+
866
+ // Move from pending to active
616
867
  this.pendingHandshakes.delete(envelope.senderId);
617
868
  this.sessions.set(envelope.senderId, session);
618
869
  this.stats.sessionsCreated++;
619
-
620
- log.info('Channel established with peer', { peerId: envelope.senderId?.slice(0, 16) });
870
+ log.info('Channel established with peer', { peerId: peerTag(envelope.senderId) });
621
871
 
622
872
  // Resolve the handshake promise
623
873
  if (session._resolveHandshake) {
@@ -628,7 +878,7 @@ export class Annex {
628
878
  async _handleEncrypted(envelope) {
629
879
  const session = this.sessions.get(envelope.senderId);
630
880
  if (!session || !session.established) {
631
- log.warn('No session for encrypted message', { peerId: envelope.senderId?.slice(0, 16) });
881
+ log.warn('No session for encrypted message', { peerId: peerTag(envelope.senderId) });
632
882
  return;
633
883
  }
634
884
 
@@ -653,6 +903,13 @@ export class Annex {
653
903
  payload = plaintext;
654
904
  }
655
905
 
906
+ // JHILKE: Intercept secure ANNEX control messages routed through
907
+ // the encrypted channel (bootstrap upgrade, encrypted rekey, etc.)
908
+ if (payload && payload._annexControl) {
909
+ await this._handleAnnexControl(payload._annexControl, envelope.senderId);
910
+ return;
911
+ }
912
+
656
913
  // Dispatch to handlers
657
914
  for (const handler of this.messageHandlers.values()) {
658
915
  try {
@@ -675,48 +932,58 @@ export class Annex {
675
932
  }
676
933
  }
677
934
  }
935
+
678
936
 
679
- async _handleRekey(envelope) {
680
- const session = this.sessions.get(envelope.senderId);
681
- if (!session) return;
682
-
683
- log.debug('Re-keying with peer', { peerId: envelope.senderId?.slice(0, 16) });
684
-
685
- // Respond to re-key with new key exchange
686
- session.generateKeyPair();
687
- const kemCiphertext = session.encapsulate(envelope.kemPublicKey);
688
- session.messageCount = 0;
689
-
690
- const response = new AnnexEnvelope({
691
- type: ANNEX_CONFIG.messageTypes.KEY_RESPONSE,
692
- senderId: this.identity.identity.nodeId,
693
- recipientId: envelope.senderId,
694
- sessionId: session.sessionId,
695
- kemPublicKey: bytesToHex(session.kemKeyPair.publicKey),
696
- kemCiphertext,
697
- });
698
-
699
- response.signature = this.identity.sign(response.getSigningPayload());
700
- await this._sendToMesh(envelope.senderId, response);
937
+ // KEM-based _handleRekey and _rekey REMOVED — JHILKE handles all rekeys
938
+ // deterministically via deriveRekeyKey(). Both nodes compute the same key
939
+ // after cricket coordination. No encapsulate/decapsulate dance needed.
940
+
941
+ /**
942
+ * Send an ANNEX control message securely through the existing encrypted channel.
943
+ * When a session exists, wraps the control message inside an encrypted payload.
944
+ * Falls back to raw transport only when no session exists (pre-bootstrap).
945
+ *
946
+ * This eliminates plaintext KEM exchange after bootstrap — all ANNEX control
947
+ * messages (KEY_EXCHANGE, KEY_RESPONSE, REKEY) are encrypted on the wire.
948
+ */
949
+ async _sendControlSecure(remoteNodeId, controlEnvelope) {
950
+ const session = this.sessions.get(remoteNodeId);
951
+ if (session?.established) {
952
+ // Wrap control message inside encrypted ANNEX payload
953
+ // _skipRekeyCheck prevents recursive rekey triggers
954
+ await this.send(remoteNodeId, { _annexControl: controlEnvelope.toJSON() }, { _skipRekeyCheck: true });
955
+ } else {
956
+ // No session yet — raw transport (only during pre-JHILKE or HELLO/WELCOME phase)
957
+ await this._sendToMesh(remoteNodeId, controlEnvelope);
958
+ }
701
959
  }
702
960
 
703
- async _rekey(session) {
704
- log.debug('Initiating re-key with peer', { peerId: session.remoteNodeId?.slice(0, 16) });
705
-
706
- // Generate new ephemeral keys
707
- const newPublicKey = session.generateKeyPair();
708
- session.messageCount = 0;
709
-
710
- const envelope = new AnnexEnvelope({
711
- type: ANNEX_CONFIG.messageTypes.REKEY,
712
- senderId: this.identity.identity.nodeId,
713
- recipientId: session.remoteNodeId,
714
- sessionId: session.sessionId,
715
- kemPublicKey: newPublicKey,
961
+ /**
962
+ * Handle a secure ANNEX control message received through the encrypted channel.
963
+ * The outer AEAD encryption guarantees authenticity — no separate signature
964
+ * check needed (only the session peer could have encrypted it).
965
+ */
966
+ async _handleAnnexControl(controlData, senderId) {
967
+ log.debug('Processing secure ANNEX control', {
968
+ type: controlData.type,
969
+ from: peerTag(senderId),
716
970
  });
717
971
 
718
- envelope.signature = this.identity.sign(envelope.getSigningPayload());
719
- await this._sendToMesh(session.remoteNodeId, envelope);
972
+ switch (controlData.type) {
973
+ case ANNEX_CONFIG.messageTypes.KEY_EXCHANGE:
974
+ await this._handleKeyExchange(controlData);
975
+ break;
976
+ case ANNEX_CONFIG.messageTypes.KEY_RESPONSE:
977
+ await this._handleKeyResponse(controlData);
978
+ break;
979
+ case ANNEX_CONFIG.messageTypes.CLOSE: {
980
+ const closedSession = this.sessions.get(controlData.senderId);
981
+ if (closedSession) closedSession.channelState = ChannelState.CLOSED;
982
+ this.sessions.delete(controlData.senderId);
983
+ log.info('Channel closed by peer (secure)', { peerId: peerTag(controlData.senderId) });
984
+ break;
985
+ }
986
+ }
720
987
  }
721
988
 
722
989
  async _sendToMesh(remoteNodeId, envelope) {
@@ -730,11 +997,98 @@ export class Annex {
730
997
  });
731
998
  }
732
999
 
1000
+ /**
1001
+ * Buffer an ANNEX message whose sender key hasn't arrived yet.
1002
+ * Replays automatically when 'peer-registered' fires, or discards
1003
+ * after _deferTimeoutMs (preventing memory leaks from spoofed senderIds).
1004
+ */
1005
+ _deferMessage(envelope, origin) {
1006
+ const senderId = envelope.senderId;
1007
+
1008
+ // Already deferring a message from this sender — discard the older one
1009
+ if (this._deferredMessages.has(senderId)) {
1010
+ const existing = this._deferredMessages.get(senderId);
1011
+ clearTimeout(existing.timer);
1012
+ if (existing.onRegistered) {
1013
+ this.mesh.off('peer-registered', existing.onRegistered);
1014
+ }
1015
+ this._deferredMessages.delete(senderId);
1016
+ }
1017
+
1018
+ // Cap total deferred senders to prevent memory abuse
1019
+ if (this._deferredMessages.size >= this._maxDeferredSenders) {
1020
+ log.debug('Deferred ANNEX queue full, dropping message from unknown peer', {
1021
+ peerId: peerTag(senderId),
1022
+ type: envelope.type,
1023
+ });
1024
+ return;
1025
+ }
1026
+
1027
+ log.debug('Deferring ANNEX message until peer key arrives', {
1028
+ peerId: peerTag(senderId),
1029
+ type: envelope.type,
1030
+ });
1031
+
1032
+ // Event listener: replay when peer registers
1033
+ const onRegistered = (registeredNodeId) => {
1034
+ if (registeredNodeId === senderId) {
1035
+ this._replayDeferred(senderId);
1036
+ }
1037
+ };
1038
+
1039
+ // Safety timeout: if key never arrives, discard silently
1040
+ const timer = setTimeout(() => {
1041
+ if (this._deferredMessages.has(senderId)) {
1042
+ this.mesh.off('peer-registered', onRegistered);
1043
+ this._deferredMessages.delete(senderId);
1044
+ log.debug('Deferred ANNEX message expired (peer key never arrived)', {
1045
+ peerId: peerTag(senderId),
1046
+ type: envelope.type,
1047
+ });
1048
+ }
1049
+ }, this._deferTimeoutMs);
1050
+
1051
+ this._deferredMessages.set(senderId, { envelope, origin, timer, onRegistered });
1052
+ this.mesh.on('peer-registered', onRegistered);
1053
+ }
1054
+
1055
+ /**
1056
+ * Replay a deferred ANNEX message now that the sender's key is available.
1057
+ */
1058
+ _replayDeferred(senderId) {
1059
+ const deferred = this._deferredMessages.get(senderId);
1060
+ if (!deferred) return;
1061
+
1062
+ clearTimeout(deferred.timer);
1063
+ this.mesh.off('peer-registered', deferred.onRegistered);
1064
+ this._deferredMessages.delete(senderId);
1065
+
1066
+ log.debug('Replaying deferred ANNEX message (peer key arrived)', {
1067
+ peerId: peerTag(senderId),
1068
+ type: deferred.envelope.type,
1069
+ });
1070
+
1071
+ // Re-enter the handler — this time _getPeerPublicKey should succeed
1072
+ this._handleAnnexMessage(deferred.envelope, deferred.origin).catch(err => {
1073
+ log.warn('Deferred ANNEX replay failed', { peerId: peerTag(senderId), error: err.message });
1074
+ });
1075
+ }
1076
+
733
1077
  _getPeerPublicKey(nodeId) {
734
- // Get from mesh peer info
1078
+ // Get from WS peer info first
735
1079
  if (this.mesh && this.mesh.peers) {
736
1080
  const peer = this.mesh.peers.get(nodeId);
737
- return peer?.identity?.publicKey || null;
1081
+ if (peer?.identity?.publicKey) return peer.identity.publicKey;
1082
+ }
1083
+ // Fallback: relay peer keys stored during signed registration
1084
+ if (this.mesh && this.mesh._relayPeerKeys) {
1085
+ const key = this.mesh._relayPeerKeys.get(nodeId);
1086
+ if (key) return key;
1087
+ }
1088
+ // Fallback: SHERPA registry (populated during relay registration)
1089
+ if (this.mesh && this.mesh.sherpa?.registry) {
1090
+ const regPeer = this.mesh.sherpa.registry.get(nodeId);
1091
+ if (regPeer?.publicKey) return regPeer.publicKey;
738
1092
  }
739
1093
  return null;
740
1094
  }