@claw-network/node 0.3.0 → 0.4.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.
@@ -9,12 +9,20 @@
9
9
  * - Periodic TTL cleanup of expired messages
10
10
  */
11
11
  import { generateX25519Keypair, x25519SharedSecret, hkdfSha256, encryptAes256Gcm, decryptAes256Gcm, bytesToHex, hexToBytes, } from '@claw-network/core';
12
+ import { encodeDirectMessageBytes, decodeDirectMessageBytes, encodeDeliveryReceiptBytes, decodeDeliveryReceiptBytes, encodeDidAnnounceBytes, decodeDidAnnounceBytes, encodeDidResolveRequestBytes, decodeDidResolveRequestBytes, encodeDidResolveResponseBytes, decodeDidResolveResponseBytes, encodeE2EEnvelope, decodeE2EEnvelope, } from '@claw-network/protocol/messaging';
12
13
  import { createLogger } from '../logger.js';
13
14
  import { gzipSync, gunzipSync } from 'node:zlib';
14
15
  // ── Constants ────────────────────────────────────────────────────
15
16
  const PROTO_DM = '/clawnet/1.0.0/dm';
16
17
  const PROTO_DID_ANNOUNCE = '/clawnet/1.0.0/did-announce';
17
18
  const PROTO_RECEIPT = '/clawnet/1.0.0/receipt';
19
+ const PROTO_DID_RESOLVE = '/clawnet/1.0.0/did-resolve';
20
+ /** Timeout for DID resolve queries (ms). */
21
+ const DID_RESOLVE_TIMEOUT_MS = 5_000;
22
+ /** Maximum number of peers to query in parallel for DID resolve. */
23
+ const DID_RESOLVE_MAX_PEERS = 3;
24
+ /** DID→PeerId mapping TTL: re-resolve after 30 minutes to handle stale mappings. */
25
+ const DID_PEER_TTL_MS = 30 * 60_000;
18
26
  /** Maximum payload size in bytes (64 KB). */
19
27
  const MAX_PAYLOAD_BYTES = 65_536;
20
28
  /** Cleanup interval for expired messages (5 minutes). */
@@ -27,8 +35,10 @@ const RATE_LIMIT_PER_MIN = 600;
27
35
  const RATE_LIMIT_WINDOW_MS = 60_000;
28
36
  /** Inbound P2P rate limit: max inbound messages per peer per minute. */
29
37
  const INBOUND_RATE_LIMIT = 300;
30
- /** Interval at which empty rate-limit buckets are pruned (2 minutes). */
31
- const RATE_BUCKET_GC_INTERVAL_MS = 2 * 60_000;
38
+ /** Global inbound rate limit: max total inbound messages per minute (all peers combined). */
39
+ const GLOBAL_INBOUND_RATE_LIMIT = 3000;
40
+ /** Stream read timeout in milliseconds — abort slow/stalled streams. */
41
+ const STREAM_READ_TIMEOUT_MS = 10_000;
32
42
  /** Maximum concurrency for multicast delivery. */
33
43
  const MULTICAST_CONCURRENCY = 20;
34
44
  /** Base delay for exponential backoff in outbox retry (ms). */
@@ -41,6 +51,8 @@ const DID_PATTERN = /^did:claw:z[1-9A-HJ-NP-Za-km-z]{32,64}$/;
41
51
  const COMPRESSION_THRESHOLD_BYTES = 1024;
42
52
  /** HKDF info tag for E2E messaging encryption. */
43
53
  const E2E_MSG_INFO = Buffer.from('clawnet:e2e-msg:v1', 'utf-8');
54
+ /** Topic used for delivery receipt notifications via WebSocket. */
55
+ export const RECEIPT_TOPIC = '_receipt';
44
56
  /** Priority levels — higher number = higher priority. */
45
57
  export var MessagePriority;
46
58
  (function (MessagePriority) {
@@ -50,25 +62,33 @@ export var MessagePriority;
50
62
  MessagePriority[MessagePriority["URGENT"] = 3] = "URGENT";
51
63
  })(MessagePriority || (MessagePriority = {}));
52
64
  // ── Helpers ──────────────────────────────────────────────────────
53
- /** Read all data from a stream source into a single Buffer, enforcing a size limit. */
54
- async function readStream(source, maxBytes = MAX_PAYLOAD_BYTES * 2) {
65
+ /** Read all data from a stream source into a single Buffer, enforcing a size limit and timeout. */
66
+ async function readStream(source, maxBytes = MAX_PAYLOAD_BYTES * 2, timeoutMs = STREAM_READ_TIMEOUT_MS) {
55
67
  const chunks = [];
56
68
  let total = 0;
57
- for await (const chunk of source) {
58
- const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
59
- total += bytes.length;
60
- if (total > maxBytes) {
61
- throw new Error(`Stream exceeded size limit: ${total} > ${maxBytes}`);
69
+ const ac = new AbortController();
70
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
71
+ try {
72
+ for await (const chunk of source) {
73
+ if (ac.signal.aborted)
74
+ throw new Error(`Stream read timed out after ${timeoutMs}ms`);
75
+ const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
76
+ total += bytes.length;
77
+ if (total > maxBytes) {
78
+ throw new Error(`Stream exceeded size limit: ${total} > ${maxBytes}`);
79
+ }
80
+ chunks.push(Buffer.from(bytes));
62
81
  }
63
- chunks.push(Buffer.from(bytes));
82
+ }
83
+ finally {
84
+ clearTimeout(timer);
64
85
  }
65
86
  return Buffer.concat(chunks);
66
87
  }
67
- /** Write a UTF-8 JSON string to a stream sink. */
68
- async function writeStream(sink, data) {
69
- const encoded = Buffer.from(data, 'utf-8');
88
+ /** Write raw binary data to a stream sink. */
89
+ async function writeBinaryStream(sink, data) {
70
90
  await sink((async function* () {
71
- yield encoded;
91
+ yield data;
72
92
  })());
73
93
  }
74
94
  // ── Service ──────────────────────────────────────────────────────
@@ -80,19 +100,14 @@ export class MessagingService {
80
100
  cleanupTimer;
81
101
  /**
82
102
  * DID → PeerId mapping. Populated via the did-announce protocol when
83
- * peers connect. This is a best-effort cache; entries are never evicted
84
- * but may become stale when peers go offline.
103
+ * peers connect. Persisted to SQLite and restored on startup.
85
104
  */
86
105
  didToPeerId = new Map();
87
106
  peerIdToDid = new Map();
107
+ /** Tracks when each DID→PeerId mapping was last confirmed (for TTL-based re-resolve). */
108
+ didPeerUpdatedAt = new Map();
88
109
  /** WebSocket subscribers that receive real-time inbox pushes. */
89
110
  subscribers = new Set();
90
- /** Sliding-window rate limiter: DID → array of timestamps (ms). */
91
- rateBuckets = new Map();
92
- /** Inbound rate limiter: peerId → array of timestamps. */
93
- inboundRateBuckets = new Map();
94
- /** Timer for periodic rate-bucket garbage collection. */
95
- rateBucketGcTimer;
96
111
  constructor(p2p, store, localDid) {
97
112
  this.log = createLogger({ level: 'info' });
98
113
  this.p2p = p2p;
@@ -101,36 +116,43 @@ export class MessagingService {
101
116
  }
102
117
  // ── Lifecycle ──────────────────────────────────────────────────
103
118
  async start() {
104
- // Register stream protocol handlers
119
+ // Restore persisted DID→PeerId mappings from SQLite
120
+ for (const { did, peerId, updatedAtMs } of this.store.getAllDidPeers()) {
121
+ this.didToPeerId.set(did, peerId);
122
+ this.peerIdToDid.set(peerId, did);
123
+ this.didPeerUpdatedAt.set(did, updatedAtMs);
124
+ }
125
+ this.log.info('[messaging] restored DID mappings', { count: this.didToPeerId.size });
126
+ // Register stream protocol handlers with per-protocol inbound stream limits
105
127
  await this.p2p.handleProtocol(PROTO_DM, (incoming) => {
106
128
  void this.handleInboundMessage(incoming);
107
- });
129
+ }, { maxInboundStreams: 256 });
108
130
  await this.p2p.handleProtocol(PROTO_DID_ANNOUNCE, (incoming) => {
109
131
  void this.handleDidAnnounce(incoming);
110
- });
132
+ }, { maxInboundStreams: 64 });
111
133
  await this.p2p.handleProtocol(PROTO_RECEIPT, (incoming) => {
112
134
  void this.handleDeliveryReceipt(incoming);
113
- });
135
+ }, { maxInboundStreams: 64 });
136
+ await this.p2p.handleProtocol(PROTO_DID_RESOLVE, (incoming) => {
137
+ void this.handleDidResolve(incoming);
138
+ }, { maxInboundStreams: 128 });
114
139
  // When a new peer connects, exchange DID announcements
115
140
  this.p2p.onPeerDisconnect(() => {
116
141
  // No-op for now; outbox delivery is handled via flush on connect.
117
142
  });
118
143
  // Announce our DID to all currently connected peers
119
144
  void this.announceToAll();
120
- // Periodic cleanup of expired messages
145
+ // Periodic cleanup of expired messages and stale rate-limit entries
121
146
  this.cleanupTimer = setInterval(() => {
122
147
  try {
123
148
  this.store.cleanupInbox();
124
149
  this.store.cleanupOutbox();
150
+ this.store.pruneRateEvents(Date.now() - RATE_LIMIT_WINDOW_MS);
125
151
  }
126
152
  catch {
127
153
  /* best-effort */
128
154
  }
129
155
  }, CLEANUP_INTERVAL_MS);
130
- // Periodic GC for rate-limit buckets to prevent memory leaks
131
- this.rateBucketGcTimer = setInterval(() => {
132
- this.pruneRateBuckets();
133
- }, RATE_BUCKET_GC_INTERVAL_MS);
134
156
  this.log.info('[messaging] service started', { localDid: this.localDid });
135
157
  }
136
158
  async stop() {
@@ -138,10 +160,6 @@ export class MessagingService {
138
160
  clearInterval(this.cleanupTimer);
139
161
  this.cleanupTimer = undefined;
140
162
  }
141
- if (this.rateBucketGcTimer) {
142
- clearInterval(this.rateBucketGcTimer);
143
- this.rateBucketGcTimer = undefined;
144
- }
145
163
  try {
146
164
  await this.p2p.unhandleProtocol(PROTO_DM);
147
165
  }
@@ -154,6 +172,10 @@ export class MessagingService {
154
172
  await this.p2p.unhandleProtocol(PROTO_RECEIPT);
155
173
  }
156
174
  catch { /* ignore */ }
175
+ try {
176
+ await this.p2p.unhandleProtocol(PROTO_DID_RESOLVE);
177
+ }
178
+ catch { /* ignore */ }
157
179
  this.subscribers.clear();
158
180
  }
159
181
  // ── Public API ─────────────────────────────────────────────────
@@ -168,22 +190,51 @@ export class MessagingService {
168
190
  // Rate limit check
169
191
  this.enforceRateLimit(this.localDid);
170
192
  // Apply compression + encryption to payload
171
- const { encoded, compressed, encrypted } = this.encodePayload(payload, opts);
193
+ const { payloadBytes, storagePayload, compressed, encrypted } = this.encodePayload(payload, opts);
172
194
  // Validate payload size after encoding
173
- const payloadBytes = Buffer.byteLength(encoded, 'utf-8');
174
- if (payloadBytes > MAX_PAYLOAD_BYTES) {
175
- throw new Error(`Payload too large: ${payloadBytes} bytes (max ${MAX_PAYLOAD_BYTES})`);
195
+ if (payloadBytes.length > MAX_PAYLOAD_BYTES) {
196
+ throw new Error(`Payload too large: ${payloadBytes.length} bytes (max ${MAX_PAYLOAD_BYTES})`);
176
197
  }
177
198
  const peerId = this.didToPeerId.get(targetDid);
178
199
  if (peerId) {
179
200
  // Try direct delivery
180
- const delivered = await this.deliverDirect(peerId, targetDid, topic, encoded, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
201
+ const delivered = await this.deliverDirect(peerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
181
202
  if (delivered) {
182
203
  return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
183
204
  }
205
+ // Delivery failed — if mapping is stale, try re-resolving
206
+ if (this.isStalePeerMapping(targetDid)) {
207
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
208
+ if (resolvedPeerId && resolvedPeerId !== peerId) {
209
+ this.registerDidPeer(targetDid, resolvedPeerId);
210
+ try {
211
+ await this.p2p.dialPeer(resolvedPeerId);
212
+ }
213
+ catch { /* ignore */ }
214
+ const reDelivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
215
+ if (reDelivered) {
216
+ return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
217
+ }
218
+ }
219
+ }
184
220
  }
185
- // Queue in outbox for later delivery
186
- const messageId = this.store.addToOutbox({ targetDid, topic, payload: encoded, ttlSec, priority });
221
+ // DID unknown locally ask connected peers (bootstrap/others) to resolve
222
+ if (!peerId) {
223
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
224
+ if (resolvedPeerId) {
225
+ this.registerDidPeer(targetDid, resolvedPeerId);
226
+ try {
227
+ await this.p2p.dialPeer(resolvedPeerId);
228
+ }
229
+ catch { /* peer may already be connected or unreachable */ }
230
+ const delivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
231
+ if (delivered) {
232
+ return { messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
233
+ }
234
+ }
235
+ }
236
+ // Queue in outbox for later delivery (uses string storage format)
237
+ const messageId = this.store.addToOutbox({ targetDid, topic, payload: storagePayload, ttlSec, priority });
187
238
  this.log.info('message queued in outbox', { messageId, targetDid, topic });
188
239
  return { messageId, delivered: false, compressed, encrypted };
189
240
  }
@@ -196,14 +247,14 @@ export class MessagingService {
196
247
  const priority = opts.priority ?? MessagePriority.NORMAL;
197
248
  // Rate limit check (counts as 1 call for rate-limit purposes)
198
249
  this.enforceRateLimit(this.localDid);
199
- // Apply compression (encryption not applied in multicast — each recipient needs their own key)
200
- const { encoded, compressed, encrypted } = this.encodePayload(payload, { ...opts, encryptForKeyHex: undefined });
201
- const payloadBytes = Buffer.byteLength(encoded, 'utf-8');
202
- if (payloadBytes > MAX_PAYLOAD_BYTES) {
203
- throw new Error(`Payload too large: ${payloadBytes} bytes (max ${MAX_PAYLOAD_BYTES})`);
250
+ // Pre-encode a shared payload (without per-recipient encryption)
251
+ const { payloadBytes: sharedPayloadBytes, storagePayload: sharedStoragePayload, compressed } = this.encodePayload(payload, { ...opts, encryptForKeyHex: undefined });
252
+ if (sharedPayloadBytes.length > MAX_PAYLOAD_BYTES) {
253
+ throw new Error(`Payload too large: ${sharedPayloadBytes.length} bytes (max ${MAX_PAYLOAD_BYTES})`);
204
254
  }
205
255
  // Deliver to all targets concurrently with bounded concurrency
206
- const results = await this.deliverMulticast(targetDids, topic, encoded, ttlSec, priority, compressed, encrypted, opts.idempotencyKey);
256
+ // Per-recipient E2E encryption is applied inside deliverMulticast when recipientKeys are provided
257
+ const results = await this.deliverMulticast(targetDids, topic, sharedPayloadBytes, sharedStoragePayload, ttlSec, priority, compressed, opts.recipientKeys, opts.idempotencyKey);
207
258
  return { results };
208
259
  }
209
260
  /** Query the local inbox. */
@@ -214,30 +265,44 @@ export class MessagingService {
214
265
  ackMessage(messageId) {
215
266
  return this.store.consumeMessage(messageId);
216
267
  }
217
- /** Flush outbox: attempt to deliver all pending messages for a specific DID with exponential backoff. */
268
+ /** Flush outbox: attempt to deliver all pending messages for a specific DID with bounded concurrency. */
218
269
  async flushOutboxForDid(targetDid) {
219
270
  const peerId = this.didToPeerId.get(targetDid);
220
271
  if (!peerId)
221
272
  return 0;
222
273
  const entries = this.store.getOutboxForTarget(targetDid);
223
- let delivered = 0;
224
274
  const now = Date.now();
275
+ // Pre-filter: separate eligible entries from those still in backoff or over limit
276
+ const eligible = [];
225
277
  for (const entry of entries) {
226
278
  if (entry.attempts > MAX_DELIVERY_ATTEMPTS) {
227
279
  this.store.removeFromOutbox(entry.id);
228
280
  continue;
229
281
  }
230
- // Exponential backoff: skip if too soon since last attempt
231
282
  const backoff = Math.min(OUTBOX_RETRY_BASE_MS * (2 ** entry.attempts), OUTBOX_RETRY_MAX_MS);
232
283
  const lastAttempt = entry.lastAttempt ?? 0;
233
284
  if (lastAttempt > 0 && now - lastAttempt < backoff) {
234
285
  continue;
235
286
  }
236
- this.store.recordAttempt(entry.id);
237
- const ok = await this.deliverDirect(peerId, targetDid, entry.topic, entry.payload, entry.ttlSec);
238
- if (ok) {
239
- this.store.removeFromOutbox(entry.id);
240
- delivered++;
287
+ eligible.push(entry);
288
+ }
289
+ // Deliver in batches of MULTICAST_CONCURRENCY using Promise.allSettled
290
+ let delivered = 0;
291
+ for (let i = 0; i < eligible.length; i += MULTICAST_CONCURRENCY) {
292
+ const batch = eligible.slice(i, i + MULTICAST_CONCURRENCY);
293
+ const settled = await Promise.allSettled(batch.map(async (entry) => {
294
+ this.store.recordAttempt(entry.id);
295
+ // Outbox stores storagePayload (string); convert to bytes for wire delivery
296
+ const ok = await this.deliverDirect(peerId, targetDid, entry.topic, Buffer.from(entry.payload, 'utf-8'), entry.ttlSec);
297
+ if (ok) {
298
+ this.store.removeFromOutbox(entry.id);
299
+ return true;
300
+ }
301
+ return false;
302
+ }));
303
+ for (const result of settled) {
304
+ if (result.status === 'fulfilled' && result.value)
305
+ delivered++;
241
306
  }
242
307
  }
243
308
  return delivered;
@@ -287,93 +352,64 @@ export class MessagingService {
287
352
  });
288
353
  }
289
354
  }
290
- // ── Rate Limiting ──────────────────────────────────────────────
355
+ // ── Rate Limiting (SQLite-backed for multi-instance support) ───
291
356
  /**
292
357
  * Check rate limit for a DID. Throws if limit exceeded.
293
- * Uses a sliding window of timestamps with binary search eviction.
358
+ * Uses SQLite-backed sliding window for cross-process correctness.
294
359
  */
295
360
  enforceRateLimit(did) {
296
- this.checkRateBucket(this.rateBuckets, did, RATE_LIMIT_PER_MIN);
361
+ this.checkRateBucket(`out:${did}`, RATE_LIMIT_PER_MIN);
297
362
  }
298
363
  /** Check if a DID is currently rate-limited (without consuming a slot). */
299
364
  isRateLimited(did) {
300
- const now = Date.now();
301
- const windowStart = now - RATE_LIMIT_WINDOW_MS;
302
- const timestamps = this.rateBuckets.get(did);
303
- if (!timestamps)
304
- return false;
305
- const startIdx = this.bisectLeft(timestamps, windowStart);
306
- return (timestamps.length - startIdx) >= RATE_LIMIT_PER_MIN;
365
+ const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
366
+ return this.store.countRateEvents(`out:${did}`, windowStart) >= RATE_LIMIT_PER_MIN;
307
367
  }
308
368
  /**
309
369
  * Enforce inbound rate limit for a peerId. Throws if limit exceeded.
310
370
  * Prevents P2P peers from spamming without limit.
311
371
  */
312
372
  enforceInboundRateLimit(peerId) {
313
- this.checkRateBucket(this.inboundRateBuckets, peerId, INBOUND_RATE_LIMIT);
373
+ this.checkRateBucket(`in:${peerId}`, INBOUND_RATE_LIMIT);
314
374
  }
315
375
  /**
316
- * Core rate-limit check: evict expired entries via binary search,
317
- * push new timestamp, throw if over limit.
376
+ * Enforce global aggregate inbound rate limit (all peers combined).
377
+ * Prevents total flooding even when spread across many peers.
318
378
  */
319
- checkRateBucket(buckets, key, limit) {
320
- const now = Date.now();
321
- const windowStart = now - RATE_LIMIT_WINDOW_MS;
322
- let timestamps = buckets.get(key);
323
- if (!timestamps) {
324
- timestamps = [];
325
- buckets.set(key, timestamps);
326
- }
327
- // Binary search eviction — O(log n) instead of O(n)
328
- const startIdx = this.bisectLeft(timestamps, windowStart);
329
- if (startIdx > 0) {
330
- timestamps.splice(0, startIdx);
331
- }
332
- if (timestamps.length >= limit) {
333
- throw new RateLimitError(key, limit);
334
- }
335
- timestamps.push(now);
336
- }
337
- /** Binary search: find first index where timestamps[i] >= target. */
338
- bisectLeft(arr, target) {
339
- let lo = 0, hi = arr.length;
340
- while (lo < hi) {
341
- const mid = (lo + hi) >>> 1;
342
- if (arr[mid] < target)
343
- lo = mid + 1;
344
- else
345
- hi = mid;
346
- }
347
- return lo;
348
- }
349
- /** Remove empty and stale rate-limit buckets to prevent memory leaks. */
350
- pruneRateBuckets() {
351
- const now = Date.now();
352
- const windowStart = now - RATE_LIMIT_WINDOW_MS;
353
- for (const [key, ts] of this.rateBuckets) {
354
- if (ts.length === 0 || ts[ts.length - 1] < windowStart) {
355
- this.rateBuckets.delete(key);
356
- }
357
- }
358
- for (const [key, ts] of this.inboundRateBuckets) {
359
- if (ts.length === 0 || ts[ts.length - 1] < windowStart) {
360
- this.inboundRateBuckets.delete(key);
361
- }
379
+ enforceGlobalInboundRateLimit() {
380
+ this.checkRateBucket('in:_global', GLOBAL_INBOUND_RATE_LIMIT);
381
+ }
382
+ /**
383
+ * Core rate-limit check: count events in the sliding window via SQLite,
384
+ * record a new event, throw if over limit.
385
+ */
386
+ checkRateBucket(bucket, limit) {
387
+ const windowStart = Date.now() - RATE_LIMIT_WINDOW_MS;
388
+ const count = this.store.countRateEvents(bucket, windowStart);
389
+ if (count >= limit) {
390
+ throw new RateLimitError(bucket, limit);
362
391
  }
392
+ this.store.recordRateEvent(bucket);
363
393
  }
364
394
  // ── Private: Payload Encoding (compression + encryption) ────────
365
395
  /**
366
396
  * Encode a payload: optionally compress (gzip) then optionally encrypt (X25519+AES-256-GCM).
367
- * Returns the encoded string and flags indicating what was applied.
397
+ *
398
+ * Returns:
399
+ * - `payloadBytes`: raw binary payload for FlatBuffers wire format (Uint8Array)
400
+ * - `storagePayload`: string representation for SQLite TEXT storage (backward compat)
401
+ * - `compressed` / `encrypted` flags
368
402
  */
369
403
  encodePayload(payload, opts) {
370
- let data = payload;
404
+ let data = Buffer.from(payload, 'utf-8');
405
+ let storagePayload = payload;
371
406
  let compressed = false;
372
407
  let encrypted = false;
373
408
  // Compression: gzip if enabled and payload > threshold
374
- if (opts.compress !== false && Buffer.byteLength(data, 'utf-8') > COMPRESSION_THRESHOLD_BYTES) {
375
- const gzipped = gzipSync(Buffer.from(data, 'utf-8'));
376
- data = gzipped.toString('base64');
409
+ if (opts.compress !== false && data.length > COMPRESSION_THRESHOLD_BYTES) {
410
+ data = gzipSync(data);
411
+ // Storage format: base64-encoded gzip wrapped in JSON object (backward compat with REST API)
412
+ storagePayload = JSON.stringify({ _compressed: 1, data: data.toString('base64') });
377
413
  compressed = true;
378
414
  }
379
415
  // E2E Encryption: X25519 ECDH + HKDF + AES-256-GCM
@@ -382,9 +418,16 @@ export class MessagingService {
382
418
  const ephemeral = generateX25519Keypair();
383
419
  const shared = x25519SharedSecret(ephemeral.privateKey, recipientPubKey);
384
420
  const derived = hkdfSha256(shared, undefined, new Uint8Array(E2E_MSG_INFO), 32);
385
- const plainBytes = Buffer.from(data, 'utf-8');
386
- const enc = encryptAes256Gcm(derived, new Uint8Array(plainBytes));
387
- data = JSON.stringify({
421
+ const enc = encryptAes256Gcm(derived, new Uint8Array(data));
422
+ // Wire format: binary E2E envelope (60 bytes header + ciphertext)
423
+ data = Buffer.from(encodeE2EEnvelope({
424
+ ephemeralPk: ephemeral.publicKey,
425
+ nonce: hexToBytes(enc.nonceHex),
426
+ tag: hexToBytes(enc.tagHex),
427
+ ciphertext: hexToBytes(enc.ciphertextHex),
428
+ }));
429
+ // Storage format: JSON E2E envelope (backward compat with static decryptPayload)
430
+ storagePayload = JSON.stringify({
388
431
  _e2e: 1,
389
432
  pk: bytesToHex(ephemeral.publicKey),
390
433
  n: enc.nonceHex,
@@ -393,7 +436,7 @@ export class MessagingService {
393
436
  });
394
437
  encrypted = true;
395
438
  }
396
- return { encoded: data, compressed, encrypted };
439
+ return { payloadBytes: new Uint8Array(data), storagePayload, compressed, encrypted };
397
440
  }
398
441
  /**
399
442
  * Decrypt an E2E-encrypted payload using the local node's X25519 private key.
@@ -441,19 +484,19 @@ export class MessagingService {
441
484
  let stream = null;
442
485
  try {
443
486
  stream = await this.p2p.newStream(peerId, PROTO_DM);
444
- const message = JSON.stringify({
487
+ const bytes = encodeDirectMessageBytes({
445
488
  sourceDid: this.localDid,
446
489
  targetDid,
447
490
  topic,
448
491
  payload,
449
492
  ttlSec,
450
- sentAtMs: Date.now(),
493
+ sentAtMs: BigInt(Date.now()),
451
494
  priority,
452
495
  compressed,
453
496
  encrypted,
454
- idempotencyKey,
497
+ idempotencyKey: idempotencyKey ?? '',
455
498
  });
456
- await writeStream(stream.sink, message);
499
+ await writeBinaryStream(stream.sink, bytes);
457
500
  await stream.close();
458
501
  this.log.info('message delivered', { peerId, targetDid, topic });
459
502
  return true;
@@ -482,6 +525,7 @@ export class MessagingService {
482
525
  if (remotePeer) {
483
526
  try {
484
527
  this.enforceInboundRateLimit(remotePeer);
528
+ this.enforceGlobalInboundRateLimit();
485
529
  }
486
530
  catch {
487
531
  this.log.warn('inbound rate limit exceeded, dropping stream', { peerId: remotePeer });
@@ -495,27 +539,47 @@ export class MessagingService {
495
539
  // readStream enforces size limit before reading all into memory
496
540
  const raw = await readStream(stream.source);
497
541
  await stream.close();
498
- const msg = JSON.parse(raw.toString('utf-8'));
499
- if (!msg.sourceDid || !msg.topic || !msg.payload) {
542
+ const msg = decodeDirectMessageBytes(new Uint8Array(raw));
543
+ if (!msg.sourceDid || !msg.topic || msg.payload.length === 0) {
500
544
  this.log.warn('inbound message missing required fields');
501
545
  return;
502
546
  }
547
+ // Reconstruct string payload for SQLite TEXT storage (backward compat with REST API / SDK)
548
+ let storagePayload;
549
+ if (msg.encrypted) {
550
+ // Binary E2E → JSON E2E envelope (for static decryptPayload backward compat)
551
+ const e2e = decodeE2EEnvelope(msg.payload);
552
+ storagePayload = JSON.stringify({
553
+ _e2e: 1,
554
+ pk: bytesToHex(e2e.ephemeralPk),
555
+ n: bytesToHex(e2e.nonce),
556
+ c: bytesToHex(e2e.ciphertext),
557
+ t: bytesToHex(e2e.tag),
558
+ });
559
+ }
560
+ else if (msg.compressed) {
561
+ // Raw gzip bytes → base64-encoded gzip wrapped in JSON (for static decompressPayload)
562
+ storagePayload = JSON.stringify({ _compressed: 1, data: Buffer.from(msg.payload).toString('base64') });
563
+ }
564
+ else {
565
+ // Plain UTF-8 text
566
+ storagePayload = Buffer.from(msg.payload).toString('utf-8');
567
+ }
503
568
  // Store in inbox (deduplication handled by store if idempotencyKey is present)
504
569
  const messageId = this.store.addToInbox({
505
570
  sourceDid: msg.sourceDid,
506
- targetDid: msg.targetDid ?? this.localDid,
571
+ targetDid: msg.targetDid || this.localDid,
507
572
  topic: msg.topic,
508
- payload: msg.payload,
509
- ttlSec: msg.ttlSec,
510
- sentAtMs: msg.sentAtMs,
573
+ payload: storagePayload,
574
+ ttlSec: msg.ttlSec || undefined,
575
+ sentAtMs: msg.sentAtMs ? Number(msg.sentAtMs) : undefined,
511
576
  priority: msg.priority ?? MessagePriority.NORMAL,
512
- idempotencyKey: msg.idempotencyKey,
577
+ idempotencyKey: msg.idempotencyKey || undefined,
513
578
  });
514
- // Record DID → PeerId mapping from the sender
579
+ // Record DID → PeerId mapping from the sender (persisted to SQLite)
515
580
  const remotePeerId = connection.remotePeer?.toString();
516
581
  if (remotePeerId && msg.sourceDid) {
517
- this.didToPeerId.set(msg.sourceDid, remotePeerId);
518
- this.peerIdToDid.set(remotePeerId, msg.sourceDid);
582
+ this.registerDidPeer(msg.sourceDid, remotePeerId);
519
583
  }
520
584
  this.log.info('message received', { messageId, sourceDid: msg.sourceDid, topic: msg.topic });
521
585
  // Push to WebSocket subscribers
@@ -524,7 +588,7 @@ export class MessagingService {
524
588
  messageId,
525
589
  sourceDid: msg.sourceDid,
526
590
  topic: msg.topic,
527
- payload: msg.payload,
591
+ payload: storagePayload,
528
592
  receivedAtMs: Date.now(),
529
593
  priority: msg.priority ?? MessagePriority.NORMAL,
530
594
  seq: currentSeq,
@@ -546,18 +610,32 @@ export class MessagingService {
546
610
  async handleDidAnnounce(incoming) {
547
611
  const { stream, connection } = incoming;
548
612
  try {
613
+ // Rate limit DID announcements to prevent mapping table poisoning
614
+ const remotePeerId = connection.remotePeer?.toString();
615
+ if (remotePeerId) {
616
+ try {
617
+ this.enforceInboundRateLimit(remotePeerId);
618
+ this.enforceGlobalInboundRateLimit();
619
+ }
620
+ catch {
621
+ this.log.warn('announce rate limit exceeded, dropping', { peerId: remotePeerId });
622
+ try {
623
+ await stream.close();
624
+ }
625
+ catch { /* ignore */ }
626
+ return;
627
+ }
628
+ }
549
629
  const raw = await readStream(stream.source, 1024); // DID announces are tiny
550
630
  await stream.close();
551
- const msg = JSON.parse(raw.toString('utf-8'));
552
- const remotePeerId = connection.remotePeer?.toString();
631
+ const msg = decodeDidAnnounceBytes(new Uint8Array(raw));
553
632
  // Validate DID format to prevent spoofing / garbage entries
554
633
  if (msg.did && !DID_PATTERN.test(msg.did)) {
555
634
  this.log.warn('invalid DID in announce, ignoring', { did: msg.did, peerId: remotePeerId });
556
635
  return;
557
636
  }
558
637
  if (msg.did && remotePeerId) {
559
- this.didToPeerId.set(msg.did, remotePeerId);
560
- this.peerIdToDid.set(remotePeerId, msg.did);
638
+ this.registerDidPeer(msg.did, remotePeerId);
561
639
  this.log.info('peer DID registered', { did: msg.did, peerId: remotePeerId });
562
640
  // Flush any pending outbox messages for this DID
563
641
  const flushed = await this.flushOutboxForDid(msg.did);
@@ -574,12 +652,105 @@ export class MessagingService {
574
652
  catch { /* ignore */ }
575
653
  }
576
654
  }
655
+ // ── Private: DID Resolve Protocol ────────────────────────────
656
+ async handleDidResolve(incoming) {
657
+ const { stream, connection } = incoming;
658
+ try {
659
+ const remotePeerId = connection.remotePeer?.toString();
660
+ if (remotePeerId) {
661
+ try {
662
+ this.enforceInboundRateLimit(remotePeerId);
663
+ this.enforceGlobalInboundRateLimit();
664
+ }
665
+ catch {
666
+ this.log.warn('resolve rate limit exceeded, dropping', { peerId: remotePeerId });
667
+ try {
668
+ await stream.close();
669
+ }
670
+ catch { /* ignore */ }
671
+ return;
672
+ }
673
+ }
674
+ const raw = await readStream(stream.source, 1024);
675
+ const msg = decodeDidResolveRequestBytes(new Uint8Array(raw));
676
+ if (!msg.did || !DID_PATTERN.test(msg.did)) {
677
+ const respBytes = encodeDidResolveResponseBytes({ did: msg.did ?? '', peerId: '', found: false });
678
+ await writeBinaryStream(stream.sink, respBytes);
679
+ await stream.close();
680
+ return;
681
+ }
682
+ const peerId = this.didToPeerId.get(msg.did);
683
+ const respBytes = encodeDidResolveResponseBytes(peerId
684
+ ? { did: msg.did, peerId, found: true }
685
+ : { did: msg.did, peerId: '', found: false });
686
+ await writeBinaryStream(stream.sink, respBytes);
687
+ await stream.close();
688
+ this.log.info('DID resolve handled', { did: msg.did, found: !!peerId });
689
+ }
690
+ catch (err) {
691
+ this.log.warn('failed to handle DID resolve', { error: err.message });
692
+ try {
693
+ await stream.close();
694
+ }
695
+ catch { /* ignore */ }
696
+ }
697
+ }
698
+ /**
699
+ * Query connected peers to resolve an unknown DID → PeerId.
700
+ * Sends DID resolve requests to up to DID_RESOLVE_MAX_PEERS peers concurrently.
701
+ * Returns the first PeerId found, or null if none of the queried peers know the DID.
702
+ */
703
+ async resolveDidViaPeers(targetDid) {
704
+ const connectedPeers = this.p2p.getConnections().slice(0, DID_RESOLVE_MAX_PEERS);
705
+ if (connectedPeers.length === 0)
706
+ return null;
707
+ let timer;
708
+ const timeout = new Promise((_, reject) => {
709
+ timer = setTimeout(() => reject(new Error('resolve timeout')), DID_RESOLVE_TIMEOUT_MS);
710
+ });
711
+ try {
712
+ const result = await Promise.race([
713
+ Promise.any(connectedPeers.map(async (peerId) => {
714
+ let stream = null;
715
+ try {
716
+ stream = await this.p2p.newStream(peerId, PROTO_DID_RESOLVE);
717
+ const reqBytes = encodeDidResolveRequestBytes({ did: targetDid });
718
+ await writeBinaryStream(stream.sink, reqBytes);
719
+ const raw = await readStream(stream.source, 1024, DID_RESOLVE_TIMEOUT_MS);
720
+ await stream.close();
721
+ const resp = decodeDidResolveResponseBytes(new Uint8Array(raw));
722
+ if (resp.found && resp.peerId)
723
+ return resp.peerId;
724
+ throw new Error('not found');
725
+ }
726
+ catch (err) {
727
+ if (stream) {
728
+ try {
729
+ await stream.close();
730
+ }
731
+ catch { /* ignore */ }
732
+ }
733
+ throw err;
734
+ }
735
+ })),
736
+ timeout,
737
+ ]);
738
+ return result;
739
+ }
740
+ catch {
741
+ return null;
742
+ }
743
+ finally {
744
+ clearTimeout(timer);
745
+ }
746
+ }
577
747
  /** Announce our DID to a specific peer. */
578
748
  async announceDidToPeer(peerId) {
579
749
  let stream = null;
580
750
  try {
581
751
  stream = await this.p2p.newStream(peerId, PROTO_DID_ANNOUNCE);
582
- await writeStream(stream.sink, JSON.stringify({ did: this.localDid }));
752
+ const bytes = encodeDidAnnounceBytes({ did: this.localDid });
753
+ await writeBinaryStream(stream.sink, bytes);
583
754
  await stream.close();
584
755
  }
585
756
  catch {
@@ -602,48 +773,108 @@ export class MessagingService {
602
773
  /**
603
774
  * Deliver to multiple targets concurrently with bounded concurrency.
604
775
  * Uses Promise.allSettled so one failure doesn't block others.
776
+ * Supports per-recipient E2E encryption when recipientKeys are provided.
605
777
  */
606
- async deliverMulticast(targetDids, topic, payload, ttlSec, priority = MessagePriority.NORMAL, compressed = false, encrypted = false, idempotencyKey) {
778
+ async deliverMulticast(targetDids, topic, sharedPayloadBytes, sharedStoragePayload, ttlSec, priority = MessagePriority.NORMAL, compressed = false, recipientKeys, idempotencyKey) {
607
779
  const results = [];
608
780
  // Process in batches of MULTICAST_CONCURRENCY
609
781
  for (let i = 0; i < targetDids.length; i += MULTICAST_CONCURRENCY) {
610
782
  const batch = targetDids.slice(i, i + MULTICAST_CONCURRENCY);
611
783
  const settled = await Promise.allSettled(batch.map(async (targetDid) => {
784
+ // Per-recipient E2E encryption if a key is provided for this target
785
+ let payloadBytes = sharedPayloadBytes;
786
+ let storagePayload = sharedStoragePayload;
787
+ let encrypted = false;
788
+ const recipientKeyHex = recipientKeys?.[targetDid];
789
+ if (recipientKeyHex) {
790
+ const perRecipient = this.encodePayload(Buffer.from(sharedPayloadBytes).toString('utf-8'), { encryptForKeyHex: recipientKeyHex, compress: false });
791
+ payloadBytes = perRecipient.payloadBytes;
792
+ storagePayload = perRecipient.storagePayload;
793
+ encrypted = perRecipient.encrypted;
794
+ }
612
795
  const peerId = this.didToPeerId.get(targetDid);
613
796
  if (peerId) {
614
- const delivered = await this.deliverDirect(peerId, targetDid, topic, payload, ttlSec, priority, compressed, encrypted, idempotencyKey);
797
+ const delivered = await this.deliverDirect(peerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
615
798
  if (delivered) {
616
- return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true };
799
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
800
+ }
801
+ // Delivery failed — if mapping is stale, try re-resolving
802
+ if (this.isStalePeerMapping(targetDid)) {
803
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
804
+ if (resolvedPeerId && resolvedPeerId !== peerId) {
805
+ this.registerDidPeer(targetDid, resolvedPeerId);
806
+ try {
807
+ await this.p2p.dialPeer(resolvedPeerId);
808
+ }
809
+ catch { /* ignore */ }
810
+ const reDelivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
811
+ if (reDelivered) {
812
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
813
+ }
814
+ }
815
+ }
816
+ }
817
+ // DID unknown — try resolve via connected peers
818
+ if (!peerId) {
819
+ const resolvedPeerId = await this.resolveDidViaPeers(targetDid);
820
+ if (resolvedPeerId) {
821
+ this.registerDidPeer(targetDid, resolvedPeerId);
822
+ try {
823
+ await this.p2p.dialPeer(resolvedPeerId);
824
+ }
825
+ catch { /* ignore */ }
826
+ const delivered = await this.deliverDirect(resolvedPeerId, targetDid, topic, payloadBytes, ttlSec, priority, compressed, encrypted, idempotencyKey);
827
+ if (delivered) {
828
+ return { targetDid, messageId: `msg_direct_${Date.now().toString(36)}`, delivered: true, compressed, encrypted };
829
+ }
617
830
  }
618
831
  }
619
- const messageId = this.store.addToOutbox({ targetDid, topic, payload, ttlSec, priority });
620
- return { targetDid, messageId, delivered: false };
832
+ const messageId = this.store.addToOutbox({ targetDid, topic, payload: storagePayload, ttlSec, priority });
833
+ return { targetDid, messageId, delivered: false, compressed, encrypted };
621
834
  }));
622
835
  for (const result of settled) {
623
836
  if (result.status === 'fulfilled') {
624
837
  results.push(result.value);
625
838
  }
626
839
  else {
627
- // This shouldn't normally happen since deliverDirect catches its own errors
628
840
  this.log.warn('multicast delivery error', { error: String(result.reason) });
629
841
  }
630
842
  }
631
843
  }
632
844
  return results;
633
845
  }
846
+ // ── Private: DID → PeerId Persistence ──────────────────────────
847
+ /** Update in-memory maps AND persist the DID→PeerId mapping to SQLite. */
848
+ registerDidPeer(did, peerId) {
849
+ this.didToPeerId.set(did, peerId);
850
+ this.peerIdToDid.set(peerId, did);
851
+ this.didPeerUpdatedAt.set(did, Date.now());
852
+ try {
853
+ this.store.upsertDidPeer(did, peerId);
854
+ }
855
+ catch {
856
+ // Best-effort persistence — in-memory map is authoritative
857
+ }
858
+ }
859
+ /** Check if a DID→PeerId mapping is older than the TTL threshold. */
860
+ isStalePeerMapping(did) {
861
+ const updatedAt = this.didPeerUpdatedAt.get(did) ?? 0;
862
+ return (Date.now() - updatedAt) > DID_PEER_TTL_MS;
863
+ }
634
864
  // ── Private: Delivery Receipt Protocol ─────────────────────────
635
865
  /** Send a delivery receipt to the sender after receiving a message. */
636
866
  async sendDeliveryReceipt(peerId, messageId, recipientDid) {
637
867
  let stream = null;
638
868
  try {
639
869
  stream = await this.p2p.newStream(peerId, PROTO_RECEIPT);
640
- await writeStream(stream.sink, JSON.stringify({
641
- type: 'delivered',
870
+ const bytes = encodeDeliveryReceiptBytes({
871
+ type: 0 /* ReceiptType.Delivered */,
642
872
  messageId,
643
873
  recipientDid: this.localDid,
644
874
  senderDid: recipientDid,
645
- deliveredAtMs: Date.now(),
646
- }));
875
+ deliveredAtMs: BigInt(Date.now()),
876
+ });
877
+ await writeBinaryStream(stream.sink, bytes);
647
878
  await stream.close();
648
879
  this.log.info('delivery receipt sent', { peerId, messageId });
649
880
  }
@@ -659,25 +890,48 @@ export class MessagingService {
659
890
  }
660
891
  /** Handle an incoming delivery receipt from a remote peer. */
661
892
  async handleDeliveryReceipt(incoming) {
662
- const { stream } = incoming;
893
+ const { stream, connection } = incoming;
663
894
  try {
895
+ // Rate limit receipts to prevent receipt flooding
896
+ const remotePeerId = connection.remotePeer?.toString();
897
+ if (remotePeerId) {
898
+ try {
899
+ this.enforceInboundRateLimit(remotePeerId);
900
+ this.enforceGlobalInboundRateLimit();
901
+ }
902
+ catch {
903
+ this.log.warn('receipt rate limit exceeded, dropping', { peerId: remotePeerId });
904
+ try {
905
+ await stream.close();
906
+ }
907
+ catch { /* ignore */ }
908
+ return;
909
+ }
910
+ }
664
911
  const raw = await readStream(stream.source);
665
912
  await stream.close();
666
- const receipt = JSON.parse(raw.toString('utf-8'));
667
- if (receipt.type === 'delivered' && receipt.messageId) {
913
+ const receipt = decodeDeliveryReceiptBytes(new Uint8Array(raw));
914
+ if (receipt.type === 0 /* ReceiptType.Delivered */ && receipt.messageId) {
668
915
  // Remove from outbox if it was queued
669
916
  this.store.removeFromOutbox(receipt.messageId);
670
917
  this.log.info('delivery receipt received', {
671
918
  messageId: receipt.messageId,
672
919
  recipientDid: receipt.recipientDid,
673
920
  });
674
- // Notify subscribers about the receipt
921
+ // Notify subscribers about the receipt (convert to JSON string for backward compat)
922
+ const receiptPayload = JSON.stringify({
923
+ type: 'delivered',
924
+ messageId: receipt.messageId,
925
+ recipientDid: receipt.recipientDid,
926
+ senderDid: receipt.senderDid,
927
+ deliveredAtMs: Number(receipt.deliveredAtMs),
928
+ });
675
929
  this.notifySubscribers({
676
930
  messageId: receipt.messageId,
677
931
  sourceDid: receipt.recipientDid ?? '',
678
- topic: '_receipt',
679
- payload: JSON.stringify(receipt),
680
- receivedAtMs: receipt.deliveredAtMs ?? Date.now(),
932
+ topic: RECEIPT_TOPIC,
933
+ payload: receiptPayload,
934
+ receivedAtMs: Number(receipt.deliveredAtMs) || Date.now(),
681
935
  priority: MessagePriority.NORMAL,
682
936
  seq: 0, // Receipts don't have inbox seq
683
937
  });