@meshwhisper/node 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -31,24 +31,47 @@
31
31
  // ============================================================
32
32
  import * as http from 'node:http';
33
33
  import * as https from 'node:https';
34
+ import * as nodeCrypto from 'node:crypto';
35
+ import * as path from 'node:path';
34
36
  import { WebSocketServer, WebSocket } from 'ws';
35
37
  import Database from 'better-sqlite3';
38
+ import { FederationManager, FEDERATION_SUBPROTOCOL, loadOrCreateFederationKey, loadPeersConfig, loadBlocklist, } from './federation.js';
36
39
  // ============================================================
37
40
  // Configuration
38
41
  // ============================================================
39
42
  const PORT = parseInt(process.env.PORT ?? '8080', 10);
40
- const BLOB_TTL_HOURS = parseInt(process.env.BLOB_TTL_HOURS ?? '72', 10);
43
+ // Default 30 days. The previous 72h was tight enough that anyone offline
44
+ // for a long weekend lost messages. Storage cost is small in absolute terms
45
+ // because chaff dominates the relay's traffic, not real blobs. SDK clients
46
+ // listen on the same window to ensure blobs aren't stranded under
47
+ // destHashes the recipient stops asking for.
48
+ const BLOB_TTL_HOURS = parseInt(process.env.BLOB_TTL_HOURS ?? '720', 10);
41
49
  const MAX_BLOB_SIZE = parseInt(process.env.MAX_BLOB_SIZE ?? String(256 * 1024), 10); // 256 KB
42
50
  const MAX_BLOBS_PER_HASH = parseInt(process.env.MAX_BLOBS_PER_HASH ?? '500', 10);
43
51
  const MEDIA_TTL_HOURS = parseInt(process.env.MEDIA_TTL_HOURS ?? String(7 * 24), 10); // 7 days
52
+ const MAX_ARCHIVE_SIZE = parseInt(process.env.MAX_ARCHIVE_SIZE ?? String(12 * 1024 * 1024), 10); // 12 MB
44
53
  const MAX_MEDIA_SIZE = parseInt(process.env.MAX_MEDIA_SIZE ?? String(50 * 1024 * 1024), 10); // 50 MB
45
54
  const PRUNE_INTERVAL_MS = 5 * 60 * 1000; // prune expired blobs every 5 minutes
46
55
  const PUSH_WEBHOOK_URL = process.env.PUSH_WEBHOOK_URL ?? null;
47
56
  const DB_PATH = process.env.DB_PATH ?? './meshwhisper.db';
48
- // Rate limiting (per IP, sliding window)
57
+ // Rate limiting (per IP, sliding window).
58
+ //
59
+ // Buckets are coarse on purpose — one budget per bucket per IP per minute.
60
+ // Operators tune via env vars; the defaults are deliberately generous so
61
+ // well-behaved clients almost never hit them, while still catching obvious
62
+ // abuse (scripted enumeration, blob spam, etc).
63
+ //
64
+ // TRUST_PROXY controls whether X-Forwarded-For is honoured when computing
65
+ // the per-IP key. Default OFF — a direct-exposed node would otherwise let
66
+ // an attacker spoof the header to evade limits. Set TRUST_PROXY=1 (or any
67
+ // truthy value) when running behind nginx / Caddy / Cloudflare etc., which
68
+ // is the expected production deployment shape.
49
69
  const RATE_WINDOW_MS = 60_000; // 1 minute window
50
70
  const RATE_LIMIT_MEDIA = parseInt(process.env.RATE_LIMIT_MEDIA ?? '20', 10); // uploads/min
51
- const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); // registrations/min
71
+ const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); // writes/min on directory/opks/policy
72
+ const RATE_LIMIT_READ = parseInt(process.env.RATE_LIMIT_READ ?? '300', 10); // reads/min (GETs)
73
+ const RATE_LIMIT_ARCHIVE = parseInt(process.env.RATE_LIMIT_ARCHIVE ?? '30', 10); // archive PUTs/min
74
+ const TRUST_PROXY = /^(1|true|yes|on)$/i.test(process.env.TRUST_PROXY ?? '');
52
75
  /** Canonical external base URL for constructing media download links.
53
76
  * Set this when the Node is behind a reverse proxy (nginx, Caddy, Cloudflare).
54
77
  * Example: BASE_URL=https://msg.myapp.com
@@ -61,6 +84,13 @@ const db = new Database(DB_PATH);
61
84
  // Enable WAL mode for better concurrent read performance
62
85
  db.pragma('journal_mode = WAL');
63
86
  db.pragma('foreign_keys = ON');
87
+ // Migrate existing prekey_bundles table if columns are missing (added for username support)
88
+ for (const col of ['username', 'namespace']) {
89
+ try {
90
+ db.exec(`ALTER TABLE prekey_bundles ADD COLUMN ${col} TEXT`);
91
+ }
92
+ catch { /* already exists */ }
93
+ }
64
94
  db.exec(`
65
95
  CREATE TABLE IF NOT EXISTS blobs (
66
96
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -79,8 +109,10 @@ db.exec(`
79
109
  );
80
110
 
81
111
  CREATE TABLE IF NOT EXISTS prekey_bundles (
82
- key TEXT PRIMARY KEY,
83
- bundle TEXT NOT NULL
112
+ key TEXT PRIMARY KEY,
113
+ bundle TEXT NOT NULL,
114
+ username TEXT,
115
+ namespace TEXT
84
116
  );
85
117
 
86
118
  CREATE TABLE IF NOT EXISTS media (
@@ -88,6 +120,50 @@ db.exec(`
88
120
  data BLOB NOT NULL,
89
121
  stored_at INTEGER NOT NULL
90
122
  );
123
+
124
+ CREATE TABLE IF NOT EXISTS opks (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ identity_key TEXT NOT NULL,
127
+ opk_public TEXT NOT NULL,
128
+ stored_at INTEGER NOT NULL,
129
+ UNIQUE (identity_key, opk_public)
130
+ );
131
+ CREATE INDEX IF NOT EXISTS opks_identity_key ON opks (identity_key);
132
+
133
+ -- TOFU auth for DELETE /opks. Identity_pubkey is the user's Ed25519
134
+ -- public key hex (cross-namespace). First DELETE establishes the hash;
135
+ -- subsequent DELETEs must match.
136
+ CREATE TABLE IF NOT EXISTS opk_auth (
137
+ identity_pubkey TEXT PRIMARY KEY,
138
+ auth_hash TEXT NOT NULL,
139
+ stored_at INTEGER NOT NULL
140
+ );
141
+ CREATE UNIQUE INDEX IF NOT EXISTS prekey_username_idx
142
+ ON prekey_bundles (namespace, username)
143
+ WHERE username IS NOT NULL;
144
+
145
+ CREATE TABLE IF NOT EXISTS archives (
146
+ peer_id TEXT PRIMARY KEY,
147
+ auth_hash TEXT NOT NULL,
148
+ data BLOB NOT NULL,
149
+ stored_at INTEGER NOT NULL,
150
+ size INTEGER NOT NULL
151
+ );
152
+
153
+ -- Per-namespace policy controlling username ownership semantics.
154
+ -- 'signed-transfer' (default for unrecorded namespaces): username is sticky
155
+ -- to whichever identity first claims it; a different key cannot take it
156
+ -- over via a plain re-registration. Re-publishing with the SAME key is
157
+ -- always allowed (covers bundle refresh and key-rotation flows).
158
+ -- 'last-writer-wins': legacy/opt-in behavior — a fresh key claiming a taken
159
+ -- username silently displaces the prior owner. Useful for password-derived
160
+ -- identities where re-deriving the same key from credentials is the
161
+ -- recovery story.
162
+ CREATE TABLE IF NOT EXISTS namespace_policy (
163
+ namespace TEXT PRIMARY KEY,
164
+ username_policy TEXT NOT NULL CHECK (username_policy IN ('signed-transfer', 'last-writer-wins')),
165
+ set_at INTEGER NOT NULL
166
+ );
91
167
  `);
92
168
  // Prepared statements
93
169
  const stmts = {
@@ -112,37 +188,103 @@ const stmts = {
112
188
  deletePush: db.prepare('DELETE FROM push_registrations WHERE dest_hash = ?'),
113
189
  countPush: db.prepare('SELECT COUNT(*) AS cnt FROM push_registrations'),
114
190
  // prekey bundles
115
- upsertPrekey: db.prepare(`INSERT INTO prekey_bundles (key, bundle) VALUES (?, ?)
116
- ON CONFLICT(key) DO UPDATE SET bundle = excluded.bundle`),
117
- getPrekey: db.prepare('SELECT bundle FROM prekey_bundles WHERE key = ?'),
191
+ upsertPrekey: db.prepare(`INSERT INTO prekey_bundles (key, bundle, username, namespace) VALUES (?, ?, ?, ?)
192
+ ON CONFLICT(key) DO UPDATE SET
193
+ bundle = excluded.bundle,
194
+ username = COALESCE(excluded.username, username),
195
+ namespace = COALESCE(excluded.namespace, namespace)`),
196
+ getPrekey: db.prepare('SELECT bundle, username FROM prekey_bundles WHERE key = ?'),
197
+ getPrekeyByUsername: db.prepare('SELECT key, bundle FROM prekey_bundles WHERE namespace = ? AND username = ?'),
118
198
  countPrekeys: db.prepare('SELECT COUNT(*) AS cnt FROM prekey_bundles'),
199
+ // opks
200
+ insertOpk: db.prepare('INSERT OR IGNORE INTO opks (identity_key, opk_public, stored_at) VALUES (?, ?, ?)'),
201
+ countOpksForKey: db.prepare('SELECT COUNT(*) AS cnt FROM opks WHERE identity_key = ?'),
202
+ countOpks: db.prepare('SELECT COUNT(*) AS cnt FROM opks'),
203
+ // Purges every OPK row for one (namespace, publicKey) pair. Used by the
204
+ // SDK's one-shot migration after the OPK-resurrection fix to clear out
205
+ // zombie entries that the buggy bulk re-upload left behind. The auth_hash
206
+ // check (separate TOFU table below) prevents anyone other than the
207
+ // identity owner from purging the pool.
208
+ deleteOpksForKey: db.prepare('DELETE FROM opks WHERE identity_key = ?'),
209
+ // opk_auth (TOFU per identity)
210
+ getOpkAuthHash: db.prepare('SELECT auth_hash FROM opk_auth WHERE identity_pubkey = ?'),
211
+ insertOpkAuthHash: db.prepare('INSERT OR IGNORE INTO opk_auth (identity_pubkey, auth_hash, stored_at) VALUES (?, ?, ?)'),
119
212
  // media
120
213
  insertMedia: db.prepare('INSERT INTO media (id, data, stored_at) VALUES (?, ?, ?)'),
121
214
  getMedia: db.prepare('SELECT data, stored_at FROM media WHERE id = ?'),
122
215
  deleteMedia: db.prepare('DELETE FROM media WHERE id = ?'),
123
216
  pruneMedia: db.prepare('DELETE FROM media WHERE stored_at < ?'),
124
217
  countMedia: db.prepare('SELECT COUNT(*) AS cnt FROM media'),
218
+ // namespace policy
219
+ getNamespacePolicy: db.prepare('SELECT username_policy FROM namespace_policy WHERE namespace = ?'),
220
+ insertNamespacePolicy: db.prepare('INSERT OR IGNORE INTO namespace_policy (namespace, username_policy, set_at) VALUES (?, ?, ?)'),
221
+ // archives
222
+ upsertArchive: db.prepare(`INSERT INTO archives (peer_id, auth_hash, data, stored_at, size)
223
+ VALUES (?, ?, ?, ?, ?)
224
+ ON CONFLICT(peer_id) DO UPDATE SET
225
+ data = excluded.data,
226
+ stored_at = excluded.stored_at,
227
+ size = excluded.size
228
+ WHERE auth_hash = excluded.auth_hash`),
229
+ getArchiveAuthHash: db.prepare('SELECT auth_hash FROM archives WHERE peer_id = ?'),
230
+ getArchiveData: db.prepare('SELECT data FROM archives WHERE peer_id = ?'),
231
+ insertArchiveFirstTime: db.prepare('INSERT OR IGNORE INTO archives (peer_id, auth_hash, data, stored_at, size) VALUES (?, ?, ?, ?, ?)'),
232
+ countArchives: db.prepare('SELECT COUNT(*) AS cnt FROM archives'),
125
233
  };
234
+ // Atomic OPK claim: SELECT + DELETE in a single transaction so two concurrent
235
+ // initiators can never claim the same one-time pre-key.
236
+ const claimOpkTx = db.transaction((identityKey) => {
237
+ const row = db.prepare('SELECT id, opk_public FROM opks WHERE identity_key = ? LIMIT 1').get(identityKey);
238
+ if (!row)
239
+ return null;
240
+ db.prepare('DELETE FROM opks WHERE id = ?').run(row.id);
241
+ return row.opk_public;
242
+ });
126
243
  const rateLimitState = new Map();
127
- /** Returns true if the request is within limits, false if it should be rejected. */
244
+ /**
245
+ * Returns ok=true if the request is within limits. On rejection, retryAfterSeconds
246
+ * is the number of whole seconds until the current window rolls over (clamped to ≥1).
247
+ */
128
248
  function checkRateLimit(ip, bucket, maxPerWindow) {
129
249
  const key = `${bucket}:${ip}`;
130
250
  const now = Date.now();
131
251
  const entry = rateLimitState.get(key);
132
252
  if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
133
253
  rateLimitState.set(key, { count: 1, windowStart: now });
134
- return true;
254
+ return { ok: true, retryAfterSeconds: 0 };
255
+ }
256
+ if (entry.count >= maxPerWindow) {
257
+ const remainingMs = RATE_WINDOW_MS - (now - entry.windowStart);
258
+ return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil(remainingMs / 1000)) };
135
259
  }
136
- if (entry.count >= maxPerWindow)
137
- return false;
138
260
  entry.count++;
261
+ return { ok: true, retryAfterSeconds: 0 };
262
+ }
263
+ /**
264
+ * Apply a rate limit at the start of a request handler. On rejection writes
265
+ * a 429 with a Retry-After header and returns true (caller should `return`).
266
+ * On accept returns false (caller continues).
267
+ */
268
+ function rateLimited(req, res, bucket, maxPerWindow) {
269
+ const r = checkRateLimit(getClientIp(req), bucket, maxPerWindow);
270
+ if (r.ok)
271
+ return false;
272
+ if (metrics.rateLimitRejections[bucket] !== undefined) {
273
+ metrics.rateLimitRejections[bucket]++;
274
+ }
275
+ res.setHeader('Retry-After', String(r.retryAfterSeconds));
276
+ sendJson(res, 429, { error: 'Too many requests', retryAfter: r.retryAfterSeconds });
139
277
  return true;
140
278
  }
141
279
  function getClientIp(req) {
142
- // Respect X-Forwarded-For when behind a proxy
143
- const forwarded = req.headers['x-forwarded-for'];
144
- if (typeof forwarded === 'string')
145
- return forwarded.split(',')[0].trim();
280
+ // X-Forwarded-For is only honoured when the operator has set TRUST_PROXY,
281
+ // otherwise an attacker on a direct-exposed node could spoof the header
282
+ // to evade per-IP rate limiting.
283
+ if (TRUST_PROXY) {
284
+ const forwarded = req.headers['x-forwarded-for'];
285
+ if (typeof forwarded === 'string')
286
+ return forwarded.split(',')[0].trim();
287
+ }
146
288
  return req.socket.remoteAddress ?? 'unknown';
147
289
  }
148
290
  function pruneRateLimitState() {
@@ -153,6 +295,28 @@ function pruneRateLimitState() {
153
295
  }
154
296
  }
155
297
  // ============================================================
298
+ // Observability counters — surfaced via the /metrics endpoint in
299
+ // Prometheus text exposition format. Cheap, in-process, reset on
300
+ // every restart (which is what Prometheus expects from counters).
301
+ // ============================================================
302
+ const NODE_STARTED_AT_MS = Date.now();
303
+ const metrics = {
304
+ httpRequestsTotal: 0,
305
+ // Indexed by Prometheus-style status family label ("2xx", "3xx", etc.)
306
+ // plus exact "429" because that's the one operators alert on most.
307
+ httpStatus: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, '429': 0 },
308
+ rateLimitRejections: { dir: 0, media: 0, read: 0, archive: 0 },
309
+ websocketConnectionsTotal: 0,
310
+ };
311
+ function recordHttpStatus(status) {
312
+ metrics.httpRequestsTotal++;
313
+ if (status === 429)
314
+ metrics.httpStatus['429']++;
315
+ const family = `${Math.floor(status / 100)}xx`;
316
+ if (metrics.httpStatus[family] !== undefined)
317
+ metrics.httpStatus[family]++;
318
+ }
319
+ // ============================================================
156
320
  // Packet header constants (must match SDK wire format)
157
321
  //
158
322
  // Binary layout (all big-endian):
@@ -184,7 +348,8 @@ function storeBlob(destHash, data) {
184
348
  if (oldest)
185
349
  stmts.deleteBlob.run(oldest.id);
186
350
  }
187
- stmts.insertBlob.run(destHash, Buffer.from(data), Date.now());
351
+ const result = stmts.insertBlob.run(destHash, Buffer.from(data), Date.now());
352
+ return Number(result.lastInsertRowid);
188
353
  }
189
354
  function pullBlobs(destHash) {
190
355
  const rows = stmts.pullBlobs.all(destHash);
@@ -252,12 +417,124 @@ function notifyPush(destHash, reg) {
252
417
  function directoryKey(namespace, publicKey) {
253
418
  return `${namespace}:${publicKey}`;
254
419
  }
255
- function registerPrekey(namespace, publicKey, bundle) {
256
- stmts.upsertPrekey.run(directoryKey(namespace, publicKey), bundle);
420
+ const USERNAME_RE = /^[a-z0-9_-]{3,30}$/;
421
+ function validateUsername(raw) {
422
+ const u = raw.toLowerCase().trim();
423
+ return USERNAME_RE.test(u) ? u : null;
424
+ }
425
+ function getNamespaceUsernamePolicy(namespace) {
426
+ const row = stmts.getNamespacePolicy.get(namespace);
427
+ return row?.username_policy ?? 'signed-transfer';
428
+ }
429
+ // Wire format for username-transfer authorizations. The signed bytes MUST
430
+ // match what the SDK produces; see `src/sdk/username-transfer.ts`. Bumping
431
+ // the version tag breaks old tokens — fine, transfer tokens are short-lived.
432
+ function canonicalTransferMessage(namespace, username, toPublicKeyHex, expiresAt) {
433
+ return Buffer.from([
434
+ 'meshwhisper.username-transfer.v1',
435
+ namespace,
436
+ username,
437
+ toPublicKeyHex,
438
+ String(expiresAt),
439
+ ].join('\n'), 'utf8');
440
+ }
441
+ // DER SubjectPublicKeyInfo prefix for a raw Ed25519 public key.
442
+ // Lets `nodeCrypto.createPublicKey` ingest a 32-byte key without
443
+ // pulling in an external Ed25519 library.
444
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
445
+ function verifyTransferAuth(namespace, username, newOwnerPublicKeyHex, currentOwnerPublicKeyHex, auth) {
446
+ if (typeof auth.expiresAt !== 'number' || !Number.isFinite(auth.expiresAt))
447
+ return false;
448
+ if (Date.now() > auth.expiresAt)
449
+ return false;
450
+ // fromPublicKey in the request is informational; the relay binds
451
+ // verification to the actual current owner. We still cross-check
452
+ // so an obviously-mismatched token is rejected early.
453
+ if (typeof auth.fromPublicKey === 'string' &&
454
+ auth.fromPublicKey.toLowerCase() !== currentOwnerPublicKeyHex.toLowerCase()) {
455
+ return false;
456
+ }
457
+ let signatureBytes;
458
+ try {
459
+ signatureBytes = Buffer.from(auth.signature, 'base64');
460
+ }
461
+ catch {
462
+ return false;
463
+ }
464
+ if (signatureBytes.length !== 64)
465
+ return false;
466
+ let publicKeyBytes;
467
+ try {
468
+ publicKeyBytes = Buffer.from(currentOwnerPublicKeyHex, 'hex');
469
+ }
470
+ catch {
471
+ return false;
472
+ }
473
+ if (publicKeyBytes.length !== 32)
474
+ return false;
475
+ let publicKey;
476
+ try {
477
+ publicKey = nodeCrypto.createPublicKey({
478
+ key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]),
479
+ format: 'der',
480
+ type: 'spki',
481
+ });
482
+ }
483
+ catch {
484
+ return false;
485
+ }
486
+ const message = canonicalTransferMessage(namespace, username, newOwnerPublicKeyHex, auth.expiresAt);
487
+ try {
488
+ return nodeCrypto.verify(null, message, publicKey, signatureBytes);
489
+ }
490
+ catch {
491
+ return false;
492
+ }
257
493
  }
258
- function lookupPrekey(namespace, publicKey) {
494
+ const registerPrekeyTx = db.transaction((namespace, publicKey, bundle, username, transferAuth) => {
495
+ const key = directoryKey(namespace, publicKey);
496
+ if (username) {
497
+ const existing = stmts.getPrekeyByUsername.get(namespace, username);
498
+ if (existing && existing.key !== key) {
499
+ const policy = getNamespaceUsernamePolicy(namespace);
500
+ if (policy === 'last-writer-wins') {
501
+ // Legacy/opt-in: displace the prior owner without proof.
502
+ db.prepare('DELETE FROM prekey_bundles WHERE key = ?').run(existing.key);
503
+ }
504
+ else if (transferAuth) {
505
+ // Signed-transfer policy with handover token: verify the prior owner
506
+ // authorized this takeover. existing.key is `${namespace}:${publicKey}`;
507
+ // splitting on the last colon recovers the publicKey hex (which itself
508
+ // never contains a colon).
509
+ const lastColon = existing.key.lastIndexOf(':');
510
+ const currentOwnerHex = lastColon >= 0 ? existing.key.slice(lastColon + 1) : '';
511
+ const ok = verifyTransferAuth(namespace, username, publicKey, currentOwnerHex, transferAuth);
512
+ if (!ok)
513
+ return 'invalid_transfer_auth';
514
+ db.prepare('DELETE FROM prekey_bundles WHERE key = ?').run(existing.key);
515
+ }
516
+ else {
517
+ // Signed-transfer policy, no token — reject takeover.
518
+ return 'username_taken';
519
+ }
520
+ }
521
+ }
522
+ stmts.upsertPrekey.run(key, bundle, username, namespace);
523
+ return 'ok';
524
+ });
525
+ function lookupPrekeyByPublicKey(namespace, publicKey) {
259
526
  const row = stmts.getPrekey.get(directoryKey(namespace, publicKey));
260
- return row?.bundle ?? null;
527
+ if (!row)
528
+ return null;
529
+ return { bundle: row.bundle, ...(row.username ? { username: row.username } : {}) };
530
+ }
531
+ function lookupPrekeyByUsername(namespace, username) {
532
+ const row = stmts.getPrekeyByUsername.get(namespace, username);
533
+ if (!row)
534
+ return null;
535
+ // key format is "namespace:publicKey"
536
+ const publicKey = row.key.slice(namespace.length + 1);
537
+ return { bundle: row.bundle, publicKey };
261
538
  }
262
539
  // ============================================================
263
540
  // Media store — SQLite-backed, TTL-based encrypted blob storage
@@ -296,6 +573,32 @@ function pruneExpiredMedia() {
296
573
  const clientsByHash = new Map();
297
574
  /** Reverse map so we can clean up on disconnect. */
298
575
  const hashesPerClient = new Map();
576
+ const activitySubscribers = new Set();
577
+ let activityBucket = { in: 0, fwd: 0, queue: 0, wake: 0, drain: 0 };
578
+ function bumpActivity(type) {
579
+ if (activitySubscribers.size === 0)
580
+ return; // no-op when nobody is watching
581
+ activityBucket[type] += 1;
582
+ }
583
+ function flushActivity() {
584
+ if (activitySubscribers.size === 0)
585
+ return;
586
+ const total = activityBucket.in + activityBucket.fwd + activityBucket.queue +
587
+ activityBucket.wake + activityBucket.drain;
588
+ if (total === 0)
589
+ return;
590
+ const payload = `data: ${JSON.stringify(activityBucket)}\n\n`;
591
+ for (const res of activitySubscribers) {
592
+ try {
593
+ res.write(payload);
594
+ }
595
+ catch { /* dead connection — cleaned on close */ }
596
+ }
597
+ activityBucket = { in: 0, fwd: 0, queue: 0, wake: 0, drain: 0 };
598
+ }
599
+ // Flush at ~4 Hz: fast enough to feel live, slow enough to obscure
600
+ // individual sends and keep CPU usage trivial.
601
+ setInterval(flushActivity, 250);
299
602
  function registerClient(ws, destHashes) {
300
603
  const existing = hashesPerClient.get(ws);
301
604
  if (existing) {
@@ -325,6 +628,7 @@ function deliverQueuedBlobs(ws, destHashes) {
325
628
  for (const blob of blobs) {
326
629
  if (ws.readyState === WebSocket.OPEN) {
327
630
  ws.send(blob, { binary: true });
631
+ bumpActivity('drain');
328
632
  }
329
633
  }
330
634
  }
@@ -338,34 +642,61 @@ function handleRelayPacket(data, sender) {
338
642
  const destHash = readDestHash(data);
339
643
  if (!destHash)
340
644
  return; // malformed header
645
+ bumpActivity('in');
646
+ // Always store the blob first so it survives regardless of delivery outcome.
647
+ // This prevents a race where the recipient's WebSocket is still in the
648
+ // registry (close event not yet fired) but the peer has already called
649
+ // terminate(): we'd forward to the stale socket and the store-and-forward
650
+ // delivery on reconnect would never happen.
651
+ const blobId = storeBlob(destHash, data);
341
652
  const recipient = clientsByHash.get(destHash);
342
653
  if (recipient && recipient !== sender && recipient.readyState === WebSocket.OPEN) {
343
- // Recipient is connected — forward directly
344
- recipient.send(data, { binary: true });
654
+ // Recipient appears connected — deliver immediately AND delete from store.
655
+ // If the send fails (connection dropped between readyState check and write),
656
+ // the blob stays in the store and will be delivered on the next reconnect.
657
+ recipient.send(data, { binary: true }, (err) => {
658
+ if (!err) {
659
+ // Delivery succeeded — purge from store so it isn't delivered twice
660
+ stmts.deleteBlob.run(blobId);
661
+ bumpActivity('fwd');
662
+ }
663
+ // On error: blob remains in store for reconnect delivery
664
+ });
345
665
  }
346
666
  else {
347
- // Recipient offline (or same socket) — store for later delivery
348
- storeBlob(destHash, data);
349
- // Wake the recipient via push if they have a registered token
667
+ // Recipient offline (or same socket) — already stored above.
668
+ bumpActivity('queue');
669
+ // Wake the recipient via push if they have a registered token.
350
670
  const pushReg = getPushRegistration(destHash);
351
- if (pushReg)
671
+ if (pushReg) {
352
672
  notifyPush(destHash, pushReg);
673
+ bumpActivity('wake');
674
+ }
675
+ else if (federation) {
676
+ // No connected client and no push registration — this destHash may
677
+ // be homed on a federated peer. Forward best-effort. The local
678
+ // store (above) is kept as a harmless safety net; it expires per
679
+ // BLOB_TTL like everything else.
680
+ federation.forwardFromLocal(data);
681
+ }
353
682
  }
354
683
  }
355
684
  // ============================================================
356
685
  // WebSocket server
357
686
  // ============================================================
358
687
  function handleWebSocketConnection(ws) {
359
- ws.on('message', (raw) => {
360
- if (raw instanceof ArrayBuffer) {
361
- handleRelayPacket(new Uint8Array(raw), ws);
362
- return;
363
- }
364
- if (Buffer.isBuffer(raw)) {
365
- handleRelayPacket(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), ws);
688
+ ws.on('message', (raw, isBinary) => {
689
+ // Binary frames are relay packets
690
+ if (isBinary) {
691
+ if (raw instanceof ArrayBuffer) {
692
+ handleRelayPacket(new Uint8Array(raw), ws);
693
+ }
694
+ else if (Buffer.isBuffer(raw)) {
695
+ handleRelayPacket(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), ws);
696
+ }
366
697
  return;
367
698
  }
368
- // JSON control message
699
+ // Text frames are JSON control messages
369
700
  try {
370
701
  const msg = JSON.parse(raw.toString());
371
702
  if (msg.type === 'hello' && Array.isArray(msg.destHashes)) {
@@ -422,6 +753,7 @@ function sendJson(res, status, body) {
422
753
  'Access-Control-Allow-Origin': '*',
423
754
  });
424
755
  res.end(payload);
756
+ recordHttpStatus(status);
425
757
  }
426
758
  async function handleHttp(req, res) {
427
759
  const url = new URL(req.url ?? '/', `http://localhost`);
@@ -430,12 +762,42 @@ async function handleHttp(req, res) {
430
762
  if (method === 'OPTIONS') {
431
763
  res.writeHead(204, {
432
764
  'Access-Control-Allow-Origin': '*',
433
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
434
- 'Access-Control-Allow-Headers': 'Content-Type',
765
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
766
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
435
767
  });
436
768
  res.end();
437
769
  return;
438
770
  }
771
+ // Anonymous live activity stream (Server-Sent Events) — public, no auth.
772
+ // Emits a JSON object every ~250ms when the relay handled any traffic in
773
+ // that window. Counts only — no destination hashes, no IPs, no per-event
774
+ // metadata. Safe to expose to anyone watching the public site.
775
+ if (url.pathname === '/events' && method === 'GET') {
776
+ // Per-IP cap on SSE subscriptions. Each subscriber holds a long-lived
777
+ // connection; cap prevents a single peer from exhausting socket budget.
778
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
779
+ return;
780
+ res.writeHead(200, {
781
+ 'Content-Type': 'text/event-stream',
782
+ 'Cache-Control': 'no-cache, no-store',
783
+ 'Connection': 'keep-alive',
784
+ 'X-Accel-Buffering': 'no', // disable nginx buffering for SSE
785
+ 'Access-Control-Allow-Origin': '*',
786
+ });
787
+ res.write(': connected\n\n');
788
+ activitySubscribers.add(res);
789
+ const heartbeat = setInterval(() => {
790
+ try {
791
+ res.write(': hb\n\n');
792
+ }
793
+ catch { /* ignored */ }
794
+ }, 15_000);
795
+ req.on('close', () => {
796
+ clearInterval(heartbeat);
797
+ activitySubscribers.delete(res);
798
+ });
799
+ return;
800
+ }
439
801
  // Health check
440
802
  if (url.pathname === '/health' && method === 'GET') {
441
803
  sendJson(res, 200, {
@@ -445,16 +807,77 @@ async function handleHttp(req, res) {
445
807
  prekeyEntries: stmts.countPrekeys.get().cnt,
446
808
  pushRegistrations: stmts.countPush.get().cnt,
447
809
  mediaEntries: stmts.countMedia.get().cnt,
810
+ opkEntries: stmts.countOpks.get().cnt,
811
+ archiveEntries: stmts.countArchives.get().cnt,
812
+ federationPeersConfigured: federation?.stats.peersConfigured ?? 0,
813
+ federationPeersConnected: federation?.connectedPeerCount() ?? 0,
448
814
  });
449
815
  return;
450
816
  }
817
+ // Prometheus-format metrics. Includes gauges (current state) and
818
+ // counters (cumulative since boot). Not rate-limited so a scraper
819
+ // can poll on its own schedule. Operators wanting to keep this
820
+ // private should gate it at the reverse proxy.
821
+ if (url.pathname === '/metrics' && method === 'GET') {
822
+ const lines = [];
823
+ const emit = (name, help, type, value, labels) => {
824
+ lines.push(`# HELP ${name} ${help}`);
825
+ lines.push(`# TYPE ${name} ${type}`);
826
+ const labelStr = labels
827
+ ? `{${Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',')}}`
828
+ : '';
829
+ lines.push(`${name}${labelStr} ${value}`);
830
+ };
831
+ const emitLabeled = (name, help, type, labelName, values) => {
832
+ lines.push(`# HELP ${name} ${help}`);
833
+ lines.push(`# TYPE ${name} ${type}`);
834
+ for (const [label, value] of Object.entries(values)) {
835
+ lines.push(`${name}{${labelName}="${label}"} ${value}`);
836
+ }
837
+ };
838
+ // Uptime
839
+ emit('meshwhisper_uptime_seconds', 'Seconds since this node started', 'gauge', Math.floor((Date.now() - NODE_STARTED_AT_MS) / 1000));
840
+ // Live counts (gauges)
841
+ emit('meshwhisper_clients_connected', 'Currently connected WebSocket clients', 'gauge', clientsByHash.size);
842
+ emit('meshwhisper_stored_blobs', 'Encrypted blobs currently queued for offline delivery', 'gauge', stmts.countBlobs.get().cnt);
843
+ emit('meshwhisper_prekey_entries', 'Registered prekey-bundle entries in the directory', 'gauge', stmts.countPrekeys.get().cnt);
844
+ emit('meshwhisper_push_registrations', 'Active push-notification registrations', 'gauge', stmts.countPush.get().cnt);
845
+ emit('meshwhisper_media_entries', 'Encrypted media blobs currently stored', 'gauge', stmts.countMedia.get().cnt);
846
+ emit('meshwhisper_opk_entries', 'Unused one-time prekeys in the pool', 'gauge', stmts.countOpks.get().cnt);
847
+ emit('meshwhisper_archive_entries', 'Stored per-identity encrypted archives', 'gauge', stmts.countArchives.get().cnt);
848
+ // Counters
849
+ emit('meshwhisper_http_requests_total', 'Total HTTP requests served since startup', 'counter', metrics.httpRequestsTotal);
850
+ emitLabeled('meshwhisper_http_responses_total', 'HTTP responses by status family (plus 429 broken out)', 'counter', 'status', metrics.httpStatus);
851
+ emitLabeled('meshwhisper_rate_limit_rejections_total', 'Rate-limit (429) rejections by bucket', 'counter', 'bucket', metrics.rateLimitRejections);
852
+ emit('meshwhisper_websocket_connections_total', 'Total WebSocket connections accepted since startup', 'counter', metrics.websocketConnectionsTotal);
853
+ // Federation (zeroes when federation is dormant)
854
+ emit('meshwhisper_federation_peers_configured', 'Federation peers in the allow-list', 'gauge', federation?.stats.peersConfigured ?? 0);
855
+ emit('meshwhisper_federation_peers_connected', 'Federation peers with an established handshake', 'gauge', federation?.connectedPeerCount() ?? 0);
856
+ emit('meshwhisper_federation_forwards_sent_total', 'PacketForward frames sent to peers', 'counter', federation?.stats.forwardsSentTotal ?? 0);
857
+ emit('meshwhisper_federation_forwards_received_total', 'PacketForward frames received from peers', 'counter', federation?.stats.forwardsReceivedTotal ?? 0);
858
+ emit('meshwhisper_federation_delivered_locally_total', 'Federation packets delivered to a connected local client', 'counter', federation?.stats.deliveredLocallyTotal ?? 0);
859
+ emit('meshwhisper_federation_stored_locally_total', 'Federation packets stored for a local offline device', 'counter', federation?.stats.storedLocallyTotal ?? 0);
860
+ emit('meshwhisper_federation_forwarded_onward_total', 'Federation packets forwarded to further peers', 'counter', federation?.stats.forwardedOnwardTotal ?? 0);
861
+ emit('meshwhisper_federation_drops_duplicate_total', 'Federation packets dropped by the packet-id cache', 'counter', federation?.stats.dropsDuplicateTotal ?? 0);
862
+ emit('meshwhisper_federation_drops_ttl_total', 'Federation packets dropped by hop-count exhaustion', 'counter', federation?.stats.dropsTtlTotal ?? 0);
863
+ emit('meshwhisper_federation_handshake_failures_total', 'Failed federation handshakes', 'counter', federation?.stats.handshakeFailuresTotal ?? 0);
864
+ emit('meshwhisper_federation_drops_rate_limited_total', 'Federation frames dropped by per-peer rate limiting', 'counter', federation?.stats.dropsRateLimitedTotal ?? 0);
865
+ emit('meshwhisper_federation_handshake_rejections_blocked_total', 'Handshakes rejected by the blocklist', 'counter', federation?.stats.handshakeRejectionsBlockedTotal ?? 0);
866
+ const body = lines.join('\n') + '\n';
867
+ res.writeHead(200, {
868
+ 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
869
+ 'Content-Length': Buffer.byteLength(body),
870
+ 'Access-Control-Allow-Origin': '*',
871
+ });
872
+ res.end(body);
873
+ recordHttpStatus(200);
874
+ return;
875
+ }
451
876
  // Register prekey bundle
452
- // POST /directory { namespace, publicKey, bundle }
877
+ // POST /directory { namespace, publicKey, bundle, username? }
453
878
  if (url.pathname === '/directory' && method === 'POST') {
454
- if (!checkRateLimit(getClientIp(req), 'dir', RATE_LIMIT_DIR)) {
455
- sendJson(res, 429, { error: 'Too many requests' });
879
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
456
880
  return;
457
- }
458
881
  let body;
459
882
  try {
460
883
  body = JSON.parse(await parseBody(req));
@@ -470,35 +893,254 @@ async function handleHttp(req, res) {
470
893
  sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, bundle' });
471
894
  return;
472
895
  }
473
- registerPrekey(namespace, publicKey, bundle);
896
+ let username = null;
897
+ if (typeof body.username === 'string' && body.username) {
898
+ username = validateUsername(body.username);
899
+ if (!username) {
900
+ sendJson(res, 400, { error: 'Invalid username: 3–30 chars, a–z 0–9 _ -' });
901
+ return;
902
+ }
903
+ }
904
+ let transferAuth = null;
905
+ if (body.transferAuth && typeof body.transferAuth === 'object') {
906
+ const ta = body.transferAuth;
907
+ if (typeof ta.fromPublicKey === 'string' &&
908
+ typeof ta.expiresAt === 'number' &&
909
+ typeof ta.signature === 'string') {
910
+ transferAuth = {
911
+ fromPublicKey: ta.fromPublicKey,
912
+ expiresAt: ta.expiresAt,
913
+ signature: ta.signature,
914
+ };
915
+ }
916
+ else {
917
+ sendJson(res, 400, { error: 'Invalid transferAuth shape' });
918
+ return;
919
+ }
920
+ }
921
+ const result = registerPrekeyTx(namespace, publicKey, bundle, username, transferAuth);
922
+ if (result === 'username_taken') {
923
+ sendJson(res, 409, {
924
+ error: 'Username already claimed by a different identity in this namespace',
925
+ });
926
+ return;
927
+ }
928
+ if (result === 'invalid_transfer_auth') {
929
+ sendJson(res, 403, {
930
+ error: 'transferAuth invalid: signature, expiry, or sender mismatch',
931
+ });
932
+ return;
933
+ }
474
934
  sendJson(res, 200, { ok: true });
475
935
  return;
476
936
  }
937
+ // Set or read namespace policy
938
+ // POST /namespace-policy { namespace, usernamePolicy }
939
+ // First writer wins. Once set, the policy is sticky. Re-POSTing the
940
+ // same value returns 200; a different value returns 409.
941
+ // GET /namespace-policy?namespace=
942
+ // Returns the effective policy. Falls back to defaults when no row.
943
+ if (url.pathname === '/namespace-policy' && method === 'POST') {
944
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
945
+ return;
946
+ let body;
947
+ try {
948
+ body = JSON.parse(await parseBody(req));
949
+ }
950
+ catch {
951
+ sendJson(res, 400, { error: 'Invalid JSON body' });
952
+ return;
953
+ }
954
+ const { namespace, usernamePolicy } = body;
955
+ if (typeof namespace !== 'string' || !namespace) {
956
+ sendJson(res, 400, { error: 'Missing required field: namespace' });
957
+ return;
958
+ }
959
+ if (usernamePolicy !== 'signed-transfer' && usernamePolicy !== 'last-writer-wins') {
960
+ sendJson(res, 400, {
961
+ error: "usernamePolicy must be 'signed-transfer' or 'last-writer-wins'",
962
+ });
963
+ return;
964
+ }
965
+ stmts.insertNamespacePolicy.run(namespace, usernamePolicy, Date.now());
966
+ const current = getNamespaceUsernamePolicy(namespace);
967
+ if (current !== usernamePolicy) {
968
+ sendJson(res, 409, {
969
+ error: 'Namespace policy already set to a different value',
970
+ currentPolicy: current,
971
+ });
972
+ return;
973
+ }
974
+ sendJson(res, 200, { ok: true, usernamePolicy: current });
975
+ return;
976
+ }
977
+ if (url.pathname === '/namespace-policy' && method === 'GET') {
978
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
979
+ return;
980
+ const namespace = url.searchParams.get('namespace');
981
+ if (!namespace) {
982
+ sendJson(res, 400, { error: 'Missing query param: namespace' });
983
+ return;
984
+ }
985
+ sendJson(res, 200, {
986
+ namespace,
987
+ usernamePolicy: getNamespaceUsernamePolicy(namespace),
988
+ });
989
+ return;
990
+ }
477
991
  // Lookup prekey bundle
478
992
  // GET /directory?namespace=&publicKey=
993
+ // GET /directory?namespace=&username=alice
479
994
  if (url.pathname === '/directory' && method === 'GET') {
995
+ // Username/publicKey lookup is the natural endpoint for an enumeration
996
+ // attempt — bucket separately from writes so a busy lookup workload
997
+ // doesn't starve registrations and vice versa.
998
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
999
+ return;
1000
+ const namespace = url.searchParams.get('namespace');
1001
+ const usernameParam = url.searchParams.get('username');
1002
+ const publicKeyParam = url.searchParams.get('publicKey');
1003
+ if (!namespace) {
1004
+ sendJson(res, 400, { error: 'Missing query param: namespace' });
1005
+ return;
1006
+ }
1007
+ if (usernameParam) {
1008
+ const username = validateUsername(usernameParam);
1009
+ if (!username) {
1010
+ sendJson(res, 400, { error: 'Invalid username' });
1011
+ return;
1012
+ }
1013
+ const result = lookupPrekeyByUsername(namespace, username);
1014
+ if (!result) {
1015
+ sendJson(res, 404, { error: 'User not found' });
1016
+ return;
1017
+ }
1018
+ sendJson(res, 200, { bundle: result.bundle, publicKey: result.publicKey, username });
1019
+ return;
1020
+ }
1021
+ if (!publicKeyParam) {
1022
+ sendJson(res, 400, { error: 'Missing query param: publicKey or username' });
1023
+ return;
1024
+ }
1025
+ const result = lookupPrekeyByPublicKey(namespace, publicKeyParam);
1026
+ if (!result) {
1027
+ sendJson(res, 404, { error: 'Prekey bundle not found' });
1028
+ return;
1029
+ }
1030
+ sendJson(res, 200, { bundle: result.bundle, ...(result.username ? { username: result.username } : {}) });
1031
+ return;
1032
+ }
1033
+ // Upload one-time pre-keys for a user
1034
+ // POST /opks { namespace, publicKey, opks: string[] } (base64 public keys)
1035
+ if (url.pathname === '/opks' && method === 'POST') {
1036
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
1037
+ return;
1038
+ let body;
1039
+ try {
1040
+ body = JSON.parse(await parseBody(req));
1041
+ }
1042
+ catch {
1043
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1044
+ return;
1045
+ }
1046
+ const { namespace, publicKey, opks } = body;
1047
+ if (typeof namespace !== 'string' || !namespace ||
1048
+ typeof publicKey !== 'string' || !publicKey ||
1049
+ !Array.isArray(opks) || opks.length === 0) {
1050
+ sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, opks[]' });
1051
+ return;
1052
+ }
1053
+ const MAX_OPKS_PER_UPLOAD = 20;
1054
+ const MAX_OPKS_PER_IDENTITY = 100;
1055
+ const batch = opks.slice(0, MAX_OPKS_PER_UPLOAD).filter((o) => typeof o === 'string' && o.length > 0);
1056
+ const identityKey = directoryKey(namespace, publicKey);
1057
+ const existing = stmts.countOpksForKey.get(identityKey).cnt;
1058
+ const canStore = Math.max(0, MAX_OPKS_PER_IDENTITY - existing);
1059
+ const toStore = batch.slice(0, canStore);
1060
+ const now = Date.now();
1061
+ for (const opk of toStore) {
1062
+ stmts.insertOpk.run(identityKey, opk, now);
1063
+ }
1064
+ sendJson(res, 200, { ok: true, stored: toStore.length });
1065
+ return;
1066
+ }
1067
+ // Purge all OPKs for a (namespace, publicKey) pair.
1068
+ // DELETE /opks { namespace, publicKey }
1069
+ // Authorization: Bearer <token>
1070
+ //
1071
+ // TOFU auth: first DELETE for a given publicKey establishes the auth hash;
1072
+ // subsequent DELETEs must present a token whose SHA-256 matches the stored
1073
+ // hash. Identity_pubkey is cross-namespace so the same person purging
1074
+ // their pool in different namespaces uses the same token.
1075
+ //
1076
+ // Used by the SDK's one-shot OPK-pool migration to clear zombie entries
1077
+ // left by the pre-fix bulk re-upload bug. Apps don't need to call this
1078
+ // directly — the SDK runs it on first init after the fix.
1079
+ if (url.pathname === '/opks' && method === 'DELETE') {
1080
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
1081
+ return;
1082
+ const auth = req.headers['authorization'];
1083
+ if (!auth || !auth.startsWith('Bearer ')) {
1084
+ sendJson(res, 401, { error: 'Missing bearer token' });
1085
+ return;
1086
+ }
1087
+ const token = auth.slice('Bearer '.length).trim();
1088
+ let body;
1089
+ try {
1090
+ body = JSON.parse(await parseBody(req));
1091
+ }
1092
+ catch {
1093
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1094
+ return;
1095
+ }
1096
+ const { namespace, publicKey } = body;
1097
+ if (typeof namespace !== 'string' || !namespace || typeof publicKey !== 'string' || !publicKey) {
1098
+ sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey' });
1099
+ return;
1100
+ }
1101
+ const incomingHash = nodeCrypto.createHash('sha256').update(token).digest('hex');
1102
+ // TOFU: insert-or-ignore establishes the hash on first call. Subsequent
1103
+ // calls succeed only if the hash matches what's already stored.
1104
+ stmts.insertOpkAuthHash.run(publicKey, incomingHash, Date.now());
1105
+ const existing = stmts.getOpkAuthHash.get(publicKey);
1106
+ if (!existing || existing.auth_hash !== incomingHash) {
1107
+ sendJson(res, 403, { error: 'Invalid OPK pool token' });
1108
+ return;
1109
+ }
1110
+ const result = stmts.deleteOpksForKey.run(directoryKey(namespace, publicKey));
1111
+ sendJson(res, 200, { ok: true, deleted: result.changes });
1112
+ return;
1113
+ }
1114
+ // Claim one one-time pre-key for a user (atomic — removes it from the pool)
1115
+ // GET /opks/claim?namespace=&publicKey=
1116
+ if (url.pathname === '/opks/claim' && method === 'GET') {
1117
+ // OPK claim is a write-like operation (each call atomically consumes a
1118
+ // one-time prekey from the pool). Bucket with the other write endpoints
1119
+ // so the directory budget gates abuse — an attacker draining a victim's
1120
+ // OPK pool to force fallback to the signed-prekey-only handshake is the
1121
+ // primary risk this addresses.
1122
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
1123
+ return;
480
1124
  const namespace = url.searchParams.get('namespace');
481
1125
  const publicKey = url.searchParams.get('publicKey');
482
1126
  if (!namespace || !publicKey) {
483
1127
  sendJson(res, 400, { error: 'Missing query params: namespace, publicKey' });
484
1128
  return;
485
1129
  }
486
- const bundle = lookupPrekey(namespace, publicKey);
487
- if (!bundle) {
488
- sendJson(res, 404, { error: 'Prekey bundle not found' });
1130
+ const opk = claimOpkTx(directoryKey(namespace, publicKey));
1131
+ if (!opk) {
1132
+ sendJson(res, 404, { error: 'No one-time pre-keys available' });
489
1133
  return;
490
1134
  }
491
- sendJson(res, 200, { bundle });
1135
+ sendJson(res, 200, { opk });
492
1136
  return;
493
1137
  }
494
1138
  // Upload encrypted media blob
495
1139
  // POST /media (binary body, Content-Length required)
496
1140
  // Returns: { id, url, expiresAt }
497
1141
  if (url.pathname === '/media' && method === 'POST') {
498
- if (!checkRateLimit(getClientIp(req), 'media', RATE_LIMIT_MEDIA)) {
499
- sendJson(res, 429, { error: 'Too many requests' });
1142
+ if (rateLimited(req, res, 'media', RATE_LIMIT_MEDIA))
500
1143
  return;
501
- }
502
1144
  const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
503
1145
  if (contentLength > MAX_MEDIA_SIZE) {
504
1146
  sendJson(res, 413, { error: `Payload too large (max ${MAX_MEDIA_SIZE} bytes)` });
@@ -535,6 +1177,8 @@ async function handleHttp(req, res) {
535
1177
  // GET /media/:id
536
1178
  const mediaMatch = url.pathname.match(/^\/media\/([0-9a-f]{32})$/);
537
1179
  if (mediaMatch && method === 'GET') {
1180
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
1181
+ return;
538
1182
  const data = fetchMedia(mediaMatch[1]);
539
1183
  if (!data) {
540
1184
  sendJson(res, 404, { error: 'Media not found or expired' });
@@ -549,6 +1193,78 @@ async function handleHttp(req, res) {
549
1193
  res.end(data);
550
1194
  return;
551
1195
  }
1196
+ // Upload encrypted archive
1197
+ // PUT /archive/:peerId Authorization: Bearer <token>
1198
+ const archivePutMatch = url.pathname.match(/^\/archive\/([0-9a-f]{64})$/);
1199
+ if (archivePutMatch && method === 'PUT') {
1200
+ if (rateLimited(req, res, 'archive', RATE_LIMIT_ARCHIVE))
1201
+ return;
1202
+ const peerId = archivePutMatch[1];
1203
+ const authHeader = req.headers['authorization'] ?? '';
1204
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
1205
+ if (!token) {
1206
+ sendJson(res, 401, { error: 'Missing Authorization header' });
1207
+ return;
1208
+ }
1209
+ const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
1210
+ if (contentLength > MAX_ARCHIVE_SIZE) {
1211
+ sendJson(res, 413, { error: `Archive too large (max ${MAX_ARCHIVE_SIZE} bytes)` });
1212
+ return;
1213
+ }
1214
+ const chunks = [];
1215
+ let received = 0;
1216
+ let aborted = false;
1217
+ await new Promise((resolve, reject) => {
1218
+ req.on('data', (chunk) => {
1219
+ received += chunk.length;
1220
+ if (received > MAX_ARCHIVE_SIZE) {
1221
+ aborted = true;
1222
+ req.destroy();
1223
+ return;
1224
+ }
1225
+ chunks.push(chunk);
1226
+ });
1227
+ req.on('end', resolve);
1228
+ req.on('error', reject);
1229
+ });
1230
+ if (aborted) {
1231
+ sendJson(res, 413, { error: `Archive too large (max ${MAX_ARCHIVE_SIZE} bytes)` });
1232
+ return;
1233
+ }
1234
+ const data = Buffer.concat(chunks);
1235
+ const incomingHash = nodeCrypto.createHash('sha256').update(token).digest('hex');
1236
+ // First write: INSERT OR IGNORE sets the auth_hash. The upsert only
1237
+ // succeeds when auth_hash matches, preventing overwrites by other parties.
1238
+ stmts.insertArchiveFirstTime.run(peerId, incomingHash, data, Date.now(), data.length);
1239
+ const existing = stmts.getArchiveAuthHash.get(peerId);
1240
+ if (!existing || existing.auth_hash !== incomingHash) {
1241
+ sendJson(res, 403, { error: 'Invalid archive token' });
1242
+ return;
1243
+ }
1244
+ stmts.upsertArchive.run(peerId, incomingHash, data, Date.now(), data.length);
1245
+ sendJson(res, 200, { ok: true, size: data.length });
1246
+ return;
1247
+ }
1248
+ // Download encrypted archive
1249
+ // GET /archive/:peerId (no auth — content is client-encrypted)
1250
+ const archiveGetMatch = url.pathname.match(/^\/archive\/([0-9a-f]{64})$/);
1251
+ if (archiveGetMatch && method === 'GET') {
1252
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
1253
+ return;
1254
+ const row = stmts.getArchiveData.get(archiveGetMatch[1]);
1255
+ if (!row) {
1256
+ sendJson(res, 404, { error: 'Archive not found' });
1257
+ return;
1258
+ }
1259
+ res.writeHead(200, {
1260
+ 'Content-Type': 'application/octet-stream',
1261
+ 'Content-Length': row.data.length,
1262
+ 'Access-Control-Allow-Origin': '*',
1263
+ 'Cache-Control': 'no-store',
1264
+ });
1265
+ res.end(row.data);
1266
+ return;
1267
+ }
552
1268
  sendJson(res, 404, { error: 'Not found' });
553
1269
  }
554
1270
  // ============================================================
@@ -560,11 +1276,89 @@ const httpServer = http.createServer((req, res) => {
560
1276
  res.writeHead(500).end();
561
1277
  });
562
1278
  });
1279
+ // ============================================================
1280
+ // Federation (docs/federation.md v1)
1281
+ //
1282
+ // Dormant unless a peers file with at least one entry exists. The
1283
+ // classifyLocal callback answers "can this packet be handled here?":
1284
+ // - a connected client holds the destHash → deliver ('delivered')
1285
+ // - a push registration exists for the destHash → store+wake ('stored';
1286
+ // the device is homed here, currently offline)
1287
+ // - neither → 'unknown' (forward onward)
1288
+ // ============================================================
1289
+ const FEDERATION_KEY_FILE = process.env.FEDERATION_KEY_FILE
1290
+ ?? path.join(path.dirname(DB_PATH), 'federation-key.json');
1291
+ const FEDERATION_PEERS_FILE = process.env.FEDERATION_PEERS_FILE
1292
+ ?? path.join(path.dirname(DB_PATH), 'federation-peers.json');
1293
+ const FEDERATION_BLOCKLIST_FILE = process.env.FEDERATION_BLOCKLIST_FILE
1294
+ ?? path.join(path.dirname(DB_PATH), 'federation-blocklist.json');
1295
+ // FEDERATION_MODE: 'off' | 'allowlist' | 'open'.
1296
+ // off — dormant regardless of peers file
1297
+ // allowlist — only pre-approved pubkeys; v1 behavior
1298
+ // open — accept any peer completing the handshake (recommended once
1299
+ // you're comfortable; per-peer rate limiting is the abuse
1300
+ // boundary). Peers file becomes the outbound bootstrap list.
1301
+ // Unset (back-compat): allowlist when the peers file has entries, else off.
1302
+ const federationPeersConfig = loadPeersConfig(FEDERATION_PEERS_FILE);
1303
+ const FEDERATION_MODE_RAW = (process.env.FEDERATION_MODE ?? '').toLowerCase();
1304
+ const federationMode = FEDERATION_MODE_RAW === 'open' ? 'open'
1305
+ : FEDERATION_MODE_RAW === 'allowlist' ? 'allowlist'
1306
+ : FEDERATION_MODE_RAW === 'off' ? 'off'
1307
+ : (federationPeersConfig.length > 0 ? 'allowlist' : 'off');
1308
+ const FEDERATION_MAX_PEERS = parseInt(process.env.FEDERATION_MAX_PEERS ?? '64', 10);
1309
+ const FEDERATION_RATE_LIMIT = parseInt(process.env.FEDERATION_RATE_LIMIT ?? '6000', 10); // frames/min/peer
1310
+ const federation = federationMode !== 'off'
1311
+ ? new FederationManager({
1312
+ key: loadOrCreateFederationKey(FEDERATION_KEY_FILE),
1313
+ peers: federationPeersConfig,
1314
+ mode: federationMode,
1315
+ blockedPubkeys: loadBlocklist(FEDERATION_BLOCKLIST_FILE),
1316
+ maxPeers: FEDERATION_MAX_PEERS,
1317
+ rateLimitPerMin: FEDERATION_RATE_LIMIT,
1318
+ classifyLocal: (packet) => {
1319
+ const destHash = readDestHash(packet);
1320
+ if (!destHash)
1321
+ return 'delivered'; // malformed — swallow, don't propagate
1322
+ const recipient = clientsByHash.get(destHash);
1323
+ if (recipient && recipient.readyState === WebSocket.OPEN) {
1324
+ // Same store-then-deliver race protection as the client path.
1325
+ const blobId = storeBlob(destHash, packet);
1326
+ recipient.send(packet, { binary: true }, (err) => {
1327
+ if (!err)
1328
+ stmts.deleteBlob.run(blobId);
1329
+ });
1330
+ bumpActivity('fwd');
1331
+ return 'delivered';
1332
+ }
1333
+ const pushReg = getPushRegistration(destHash);
1334
+ if (pushReg) {
1335
+ storeBlob(destHash, packet);
1336
+ notifyPush(destHash, pushReg);
1337
+ bumpActivity('wake');
1338
+ return 'stored';
1339
+ }
1340
+ return 'unknown';
1341
+ },
1342
+ })
1343
+ : null;
1344
+ if (federation) {
1345
+ federation.start();
1346
+ console.log(`[federation] mode=${federationMode}, ${federationPeersConfig.length} configured peer(s), max ${FEDERATION_MAX_PEERS}`);
1347
+ }
563
1348
  const wss = new WebSocketServer({ noServer: true });
564
1349
  wss.on('connection', (ws) => {
1350
+ metrics.websocketConnectionsTotal++;
565
1351
  handleWebSocketConnection(ws);
566
1352
  });
567
1353
  httpServer.on('upgrade', (req, socket, head) => {
1354
+ // Federation peers negotiate the meshwhisper-federation.v1 subprotocol;
1355
+ // everything else is a regular client-relay connection.
1356
+ const offered = (req.headers['sec-websocket-protocol'] ?? '')
1357
+ .split(',').map((s) => s.trim());
1358
+ if (federation && offered.includes(FEDERATION_SUBPROTOCOL)) {
1359
+ federation.handleUpgrade(req, socket, head);
1360
+ return;
1361
+ }
568
1362
  wss.handleUpgrade(req, socket, head, (ws) => {
569
1363
  wss.emit('connection', ws, req);
570
1364
  });
@@ -576,11 +1370,16 @@ const pruneInterval = setInterval(() => {
576
1370
  pruneRateLimitState();
577
1371
  }, PRUNE_INTERVAL_MS);
578
1372
  pruneInterval.unref();
579
- httpServer.listen(PORT, () => {
1373
+ // Default to '::' (IPv6 wildcard) which also accepts IPv4 on Linux (bindv6only=0).
1374
+ // Set LISTEN_HOST=0.0.0.0 to restrict to IPv4 only.
1375
+ const LISTEN_HOST = process.env.LISTEN_HOST ?? '::';
1376
+ httpServer.listen(PORT, LISTEN_HOST, () => {
580
1377
  console.log(`MeshWhisper Node listening on port ${PORT}`);
581
1378
  console.log(` Relay: ws://localhost:${PORT}`);
582
1379
  console.log(` Directory: http://localhost:${PORT}/directory`);
1380
+ console.log(` OPKs: http://localhost:${PORT}/opks`);
583
1381
  console.log(` Media: http://localhost:${PORT}/media`);
1382
+ console.log(` Archive: http://localhost:${PORT}/archive/:peerId`);
584
1383
  console.log(` Health: http://localhost:${PORT}/health`);
585
1384
  console.log(` Database: ${DB_PATH}`);
586
1385
  console.log(` Blob TTL: ${BLOB_TTL_HOURS}h`);