@meshwhisper/node 0.1.0 → 0.2.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.
- package/dist/federation.d.ts +99 -0
- package/dist/federation.d.ts.map +1 -0
- package/dist/federation.js +539 -0
- package/dist/federation.js.map +1 -0
- package/dist/index.js +982 -120
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
package/dist/index.js
CHANGED
|
@@ -21,55 +21,272 @@
|
|
|
21
21
|
// The webhook is responsible for sending the actual APNs/FCM notification.
|
|
22
22
|
// The Node only sends a silent wake signal — no message content is included.
|
|
23
23
|
//
|
|
24
|
+
// Persistence:
|
|
25
|
+
// Data is stored in a SQLite database (default: ./meshwhisper.db).
|
|
26
|
+
// Set DB_PATH to change the location. For Docker, mount a volume at /data
|
|
27
|
+
// and set DB_PATH=/data/meshwhisper.db so data survives container restarts.
|
|
28
|
+
//
|
|
24
29
|
// Self-hosted: one Docker container, one VPS, everything included.
|
|
25
30
|
// Foundation-hosted: same binary, run by the Foundation as public infra.
|
|
26
31
|
// ============================================================
|
|
27
32
|
import * as http from 'node:http';
|
|
28
33
|
import * as https from 'node:https';
|
|
34
|
+
import * as nodeCrypto from 'node:crypto';
|
|
35
|
+
import * as path from 'node:path';
|
|
29
36
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
37
|
+
import Database from 'better-sqlite3';
|
|
38
|
+
import { FederationManager, FEDERATION_SUBPROTOCOL, loadOrCreateFederationKey, loadPeersConfig, loadBlocklist, } from './federation.js';
|
|
30
39
|
// ============================================================
|
|
31
40
|
// Configuration
|
|
32
41
|
// ============================================================
|
|
33
42
|
const PORT = parseInt(process.env.PORT ?? '8080', 10);
|
|
34
|
-
|
|
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);
|
|
35
49
|
const MAX_BLOB_SIZE = parseInt(process.env.MAX_BLOB_SIZE ?? String(256 * 1024), 10); // 256 KB
|
|
36
50
|
const MAX_BLOBS_PER_HASH = parseInt(process.env.MAX_BLOBS_PER_HASH ?? '500', 10);
|
|
37
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
|
|
38
53
|
const MAX_MEDIA_SIZE = parseInt(process.env.MAX_MEDIA_SIZE ?? String(50 * 1024 * 1024), 10); // 50 MB
|
|
39
54
|
const PRUNE_INTERVAL_MS = 5 * 60 * 1000; // prune expired blobs every 5 minutes
|
|
40
55
|
const PUSH_WEBHOOK_URL = process.env.PUSH_WEBHOOK_URL ?? null;
|
|
41
|
-
|
|
56
|
+
const DB_PATH = process.env.DB_PATH ?? './meshwhisper.db';
|
|
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.
|
|
42
69
|
const RATE_WINDOW_MS = 60_000; // 1 minute window
|
|
43
70
|
const RATE_LIMIT_MEDIA = parseInt(process.env.RATE_LIMIT_MEDIA ?? '20', 10); // uploads/min
|
|
44
|
-
const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); //
|
|
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 ?? '');
|
|
45
75
|
/** Canonical external base URL for constructing media download links.
|
|
46
76
|
* Set this when the Node is behind a reverse proxy (nginx, Caddy, Cloudflare).
|
|
47
77
|
* Example: BASE_URL=https://msg.myapp.com
|
|
48
78
|
* If unset, the URL is inferred from the Host header (works for local dev). */
|
|
49
79
|
const BASE_URL = (process.env.BASE_URL ?? '').replace(/\/$/, '');
|
|
80
|
+
// ============================================================
|
|
81
|
+
// SQLite database setup
|
|
82
|
+
// ============================================================
|
|
83
|
+
const db = new Database(DB_PATH);
|
|
84
|
+
// Enable WAL mode for better concurrent read performance
|
|
85
|
+
db.pragma('journal_mode = WAL');
|
|
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
|
+
}
|
|
94
|
+
db.exec(`
|
|
95
|
+
CREATE TABLE IF NOT EXISTS blobs (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
dest_hash TEXT NOT NULL,
|
|
98
|
+
data BLOB NOT NULL,
|
|
99
|
+
received_at INTEGER NOT NULL
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS blobs_dest_hash ON blobs (dest_hash);
|
|
102
|
+
|
|
103
|
+
CREATE TABLE IF NOT EXISTS push_registrations (
|
|
104
|
+
dest_hash TEXT PRIMARY KEY,
|
|
105
|
+
token TEXT NOT NULL,
|
|
106
|
+
platform TEXT NOT NULL,
|
|
107
|
+
topic TEXT,
|
|
108
|
+
push_subscription TEXT
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS prekey_bundles (
|
|
112
|
+
key TEXT PRIMARY KEY,
|
|
113
|
+
bundle TEXT NOT NULL,
|
|
114
|
+
username TEXT,
|
|
115
|
+
namespace TEXT
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS media (
|
|
119
|
+
id TEXT PRIMARY KEY,
|
|
120
|
+
data BLOB NOT NULL,
|
|
121
|
+
stored_at INTEGER NOT NULL
|
|
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
|
+
);
|
|
167
|
+
`);
|
|
168
|
+
// Prepared statements
|
|
169
|
+
const stmts = {
|
|
170
|
+
// blobs
|
|
171
|
+
insertBlob: db.prepare('INSERT INTO blobs (dest_hash, data, received_at) VALUES (?, ?, ?)'),
|
|
172
|
+
countBlobsForHash: db.prepare('SELECT COUNT(*) AS cnt FROM blobs WHERE dest_hash = ?'),
|
|
173
|
+
oldestBlobIdForHash: db.prepare('SELECT id FROM blobs WHERE dest_hash = ? ORDER BY id ASC LIMIT 1'),
|
|
174
|
+
deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'),
|
|
175
|
+
pullBlobs: db.prepare('SELECT id, data FROM blobs WHERE dest_hash = ? ORDER BY id ASC'),
|
|
176
|
+
deleteBlobsByHash: db.prepare('DELETE FROM blobs WHERE dest_hash = ?'),
|
|
177
|
+
pruneBlobs: db.prepare('DELETE FROM blobs WHERE received_at < ?'),
|
|
178
|
+
countBlobs: db.prepare('SELECT COUNT(*) AS cnt FROM blobs'),
|
|
179
|
+
// push registrations
|
|
180
|
+
upsertPush: db.prepare(`INSERT INTO push_registrations (dest_hash, token, platform, topic, push_subscription)
|
|
181
|
+
VALUES (?, ?, ?, ?, ?)
|
|
182
|
+
ON CONFLICT(dest_hash) DO UPDATE SET
|
|
183
|
+
token = excluded.token,
|
|
184
|
+
platform = excluded.platform,
|
|
185
|
+
topic = excluded.topic,
|
|
186
|
+
push_subscription = excluded.push_subscription`),
|
|
187
|
+
getPush: db.prepare('SELECT token, platform, topic, push_subscription FROM push_registrations WHERE dest_hash = ?'),
|
|
188
|
+
deletePush: db.prepare('DELETE FROM push_registrations WHERE dest_hash = ?'),
|
|
189
|
+
countPush: db.prepare('SELECT COUNT(*) AS cnt FROM push_registrations'),
|
|
190
|
+
// prekey bundles
|
|
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 = ?'),
|
|
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 (?, ?, ?)'),
|
|
212
|
+
// media
|
|
213
|
+
insertMedia: db.prepare('INSERT INTO media (id, data, stored_at) VALUES (?, ?, ?)'),
|
|
214
|
+
getMedia: db.prepare('SELECT data, stored_at FROM media WHERE id = ?'),
|
|
215
|
+
deleteMedia: db.prepare('DELETE FROM media WHERE id = ?'),
|
|
216
|
+
pruneMedia: db.prepare('DELETE FROM media WHERE stored_at < ?'),
|
|
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'),
|
|
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
|
+
});
|
|
50
243
|
const rateLimitState = new Map();
|
|
51
|
-
/**
|
|
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
|
+
*/
|
|
52
248
|
function checkRateLimit(ip, bucket, maxPerWindow) {
|
|
53
249
|
const key = `${bucket}:${ip}`;
|
|
54
250
|
const now = Date.now();
|
|
55
251
|
const entry = rateLimitState.get(key);
|
|
56
252
|
if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
|
|
57
253
|
rateLimitState.set(key, { count: 1, windowStart: now });
|
|
58
|
-
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)) };
|
|
59
259
|
}
|
|
60
|
-
if (entry.count >= maxPerWindow)
|
|
61
|
-
return false;
|
|
62
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 });
|
|
63
277
|
return true;
|
|
64
278
|
}
|
|
65
279
|
function getClientIp(req) {
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
}
|
|
70
288
|
return req.socket.remoteAddress ?? 'unknown';
|
|
71
289
|
}
|
|
72
|
-
// Prune stale rate limit entries alongside blobs
|
|
73
290
|
function pruneRateLimitState() {
|
|
74
291
|
const cutoff = Date.now() - RATE_WINDOW_MS;
|
|
75
292
|
for (const [key, entry] of rateLimitState.entries()) {
|
|
@@ -78,6 +295,28 @@ function pruneRateLimitState() {
|
|
|
78
295
|
}
|
|
79
296
|
}
|
|
80
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
|
+
// ============================================================
|
|
81
320
|
// Packet header constants (must match SDK wire format)
|
|
82
321
|
//
|
|
83
322
|
// Binary layout (all big-endian):
|
|
@@ -98,53 +337,46 @@ function readDestHash(buf) {
|
|
|
98
337
|
const bytes = buf.subarray(DEST_HASH_OFFSET, DEST_HASH_OFFSET + DEST_HASH_LENGTH);
|
|
99
338
|
return Buffer.from(bytes).toString('hex');
|
|
100
339
|
}
|
|
101
|
-
|
|
102
|
-
|
|
340
|
+
// ============================================================
|
|
341
|
+
// Blob store — SQLite-backed, TTL-based
|
|
342
|
+
// ============================================================
|
|
103
343
|
function storeBlob(destHash, data) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
queue.shift(); // drop oldest when full
|
|
344
|
+
// Enforce per-hash cap: drop oldest if full
|
|
345
|
+
const row = stmts.countBlobsForHash.get(destHash);
|
|
346
|
+
if (row.cnt >= MAX_BLOBS_PER_HASH) {
|
|
347
|
+
const oldest = stmts.oldestBlobIdForHash.get(destHash);
|
|
348
|
+
if (oldest)
|
|
349
|
+
stmts.deleteBlob.run(oldest.id);
|
|
111
350
|
}
|
|
112
|
-
|
|
351
|
+
const result = stmts.insertBlob.run(destHash, Buffer.from(data), Date.now());
|
|
352
|
+
return Number(result.lastInsertRowid);
|
|
113
353
|
}
|
|
114
354
|
function pullBlobs(destHash) {
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
355
|
+
const rows = stmts.pullBlobs.all(destHash);
|
|
356
|
+
if (rows.length === 0)
|
|
117
357
|
return [];
|
|
118
|
-
|
|
119
|
-
return
|
|
358
|
+
stmts.deleteBlobsByHash.run(destHash);
|
|
359
|
+
return rows.map((r) => new Uint8Array(r.data));
|
|
120
360
|
}
|
|
121
361
|
function pruneExpiredBlobs() {
|
|
122
362
|
const cutoff = Date.now() - BLOB_TTL_HOURS * 60 * 60 * 1000;
|
|
123
|
-
|
|
124
|
-
const fresh = queue.filter((b) => b.receivedAt > cutoff);
|
|
125
|
-
if (fresh.length === 0) {
|
|
126
|
-
blobStore.delete(hash);
|
|
127
|
-
// Also expire the push token for this dest hash — if no blobs have
|
|
128
|
-
// arrived within the blob TTL window, the device has likely rotated
|
|
129
|
-
// its dest hashes and the token is stale.
|
|
130
|
-
pushTokens.delete(hash);
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
blobStore.set(hash, fresh);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
363
|
+
stmts.pruneBlobs.run(cutoff);
|
|
136
364
|
}
|
|
137
|
-
/** Map from destHash (hex) → push registration for offline wake signals. */
|
|
138
|
-
const pushTokens = new Map();
|
|
139
365
|
function registerPushTokens(destHashes, reg) {
|
|
140
366
|
for (const hash of destHashes) {
|
|
141
|
-
|
|
367
|
+
stmts.upsertPush.run(hash, reg.token, reg.platform, reg.topic ?? null, reg.pushSubscription ?? null);
|
|
142
368
|
}
|
|
143
369
|
}
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
370
|
+
function getPushRegistration(destHash) {
|
|
371
|
+
const row = stmts.getPush.get(destHash);
|
|
372
|
+
if (!row)
|
|
373
|
+
return null;
|
|
374
|
+
return {
|
|
375
|
+
token: row.token,
|
|
376
|
+
platform: row.platform,
|
|
377
|
+
...(row.topic ? { topic: row.topic } : {}),
|
|
378
|
+
...(row.push_subscription ? { pushSubscription: row.push_subscription } : {}),
|
|
379
|
+
};
|
|
148
380
|
}
|
|
149
381
|
/**
|
|
150
382
|
* Fire-and-forget POST to the configured push webhook.
|
|
@@ -180,21 +412,133 @@ function notifyPush(destHash, reg) {
|
|
|
180
412
|
}
|
|
181
413
|
}
|
|
182
414
|
// ============================================================
|
|
183
|
-
// Prekey directory —
|
|
415
|
+
// Prekey directory — SQLite-backed, namespace-scoped
|
|
184
416
|
// ============================================================
|
|
185
|
-
/** Map from `${namespace}:${publicKeyHex}` → serialized bundle (base64). */
|
|
186
|
-
const prekeyDirectory = new Map();
|
|
187
417
|
function directoryKey(namespace, publicKey) {
|
|
188
418
|
return `${namespace}:${publicKey}`;
|
|
189
419
|
}
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
493
|
+
}
|
|
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) {
|
|
526
|
+
const row = stmts.getPrekey.get(directoryKey(namespace, publicKey));
|
|
527
|
+
if (!row)
|
|
528
|
+
return null;
|
|
529
|
+
return { bundle: row.bundle, ...(row.username ? { username: row.username } : {}) };
|
|
192
530
|
}
|
|
193
|
-
function
|
|
194
|
-
|
|
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 };
|
|
195
538
|
}
|
|
196
|
-
|
|
197
|
-
|
|
539
|
+
// ============================================================
|
|
540
|
+
// Media store — SQLite-backed, TTL-based encrypted blob storage
|
|
541
|
+
// ============================================================
|
|
198
542
|
function generateMediaId() {
|
|
199
543
|
const bytes = new Uint8Array(16);
|
|
200
544
|
globalThis.crypto.getRandomValues(bytes);
|
|
@@ -203,36 +547,59 @@ function generateMediaId() {
|
|
|
203
547
|
function storeMedia(data) {
|
|
204
548
|
const id = generateMediaId();
|
|
205
549
|
const storedAt = Date.now();
|
|
206
|
-
|
|
550
|
+
stmts.insertMedia.run(id, data, storedAt);
|
|
207
551
|
return { id, expiresAt: storedAt + MEDIA_TTL_HOURS * 60 * 60 * 1000 };
|
|
208
552
|
}
|
|
209
553
|
function fetchMedia(id) {
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
554
|
+
const row = stmts.getMedia.get(id);
|
|
555
|
+
if (!row)
|
|
212
556
|
return null;
|
|
213
|
-
const expiresAt =
|
|
557
|
+
const expiresAt = row.stored_at + MEDIA_TTL_HOURS * 60 * 60 * 1000;
|
|
214
558
|
if (Date.now() > expiresAt) {
|
|
215
|
-
|
|
559
|
+
stmts.deleteMedia.run(id);
|
|
216
560
|
return null;
|
|
217
561
|
}
|
|
218
|
-
return
|
|
562
|
+
return row.data;
|
|
219
563
|
}
|
|
220
564
|
function pruneExpiredMedia() {
|
|
221
565
|
const cutoff = Date.now() - MEDIA_TTL_HOURS * 60 * 60 * 1000;
|
|
222
|
-
|
|
223
|
-
if (entry.storedAt < cutoff)
|
|
224
|
-
mediaStore.delete(id);
|
|
225
|
-
}
|
|
566
|
+
stmts.pruneMedia.run(cutoff);
|
|
226
567
|
}
|
|
227
568
|
// ============================================================
|
|
228
569
|
// Connected clients — map from destHash (hex) → WebSocket
|
|
570
|
+
// (connection state is ephemeral by nature, always in-memory)
|
|
229
571
|
// ============================================================
|
|
230
572
|
/** A client may register multiple dest hashes (current + previous epoch). */
|
|
231
573
|
const clientsByHash = new Map();
|
|
232
574
|
/** Reverse map so we can clean up on disconnect. */
|
|
233
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);
|
|
234
602
|
function registerClient(ws, destHashes) {
|
|
235
|
-
// Remove any previous registrations for this socket
|
|
236
603
|
const existing = hashesPerClient.get(ws);
|
|
237
604
|
if (existing) {
|
|
238
605
|
for (const h of existing)
|
|
@@ -250,9 +617,8 @@ function deregisterClient(ws) {
|
|
|
250
617
|
if (hashes) {
|
|
251
618
|
for (const h of hashes)
|
|
252
619
|
clientsByHash.delete(h);
|
|
253
|
-
// Push
|
|
254
|
-
// to wake the device when it is offline.
|
|
255
|
-
// the blobs in the prune cycle.
|
|
620
|
+
// Push registrations are intentionally kept after disconnect — they are
|
|
621
|
+
// needed to wake the device when it is offline.
|
|
256
622
|
hashesPerClient.delete(ws);
|
|
257
623
|
}
|
|
258
624
|
}
|
|
@@ -262,6 +628,7 @@ function deliverQueuedBlobs(ws, destHashes) {
|
|
|
262
628
|
for (const blob of blobs) {
|
|
263
629
|
if (ws.readyState === WebSocket.OPEN) {
|
|
264
630
|
ws.send(blob, { binary: true });
|
|
631
|
+
bumpActivity('drain');
|
|
265
632
|
}
|
|
266
633
|
}
|
|
267
634
|
}
|
|
@@ -275,56 +642,80 @@ function handleRelayPacket(data, sender) {
|
|
|
275
642
|
const destHash = readDestHash(data);
|
|
276
643
|
if (!destHash)
|
|
277
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);
|
|
278
652
|
const recipient = clientsByHash.get(destHash);
|
|
279
653
|
if (recipient && recipient !== sender && recipient.readyState === WebSocket.OPEN) {
|
|
280
|
-
// Recipient
|
|
281
|
-
|
|
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
|
+
});
|
|
282
665
|
}
|
|
283
666
|
else {
|
|
284
|
-
// Recipient offline (or same socket) —
|
|
285
|
-
|
|
286
|
-
// Wake the recipient via push if they have a registered token
|
|
287
|
-
const pushReg =
|
|
288
|
-
if (pushReg)
|
|
667
|
+
// Recipient offline (or same socket) — already stored above.
|
|
668
|
+
bumpActivity('queue');
|
|
669
|
+
// Wake the recipient via push if they have a registered token.
|
|
670
|
+
const pushReg = getPushRegistration(destHash);
|
|
671
|
+
if (pushReg) {
|
|
289
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
|
+
}
|
|
290
682
|
}
|
|
291
683
|
}
|
|
292
684
|
// ============================================================
|
|
293
685
|
// WebSocket server
|
|
294
686
|
// ============================================================
|
|
295
687
|
function handleWebSocketConnection(ws) {
|
|
296
|
-
ws.on('message', (raw) => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
}
|
|
303
697
|
return;
|
|
304
698
|
}
|
|
305
|
-
// JSON control
|
|
699
|
+
// Text frames are JSON control messages
|
|
306
700
|
try {
|
|
307
701
|
const msg = JSON.parse(raw.toString());
|
|
308
702
|
if (msg.type === 'hello' && Array.isArray(msg.destHashes)) {
|
|
309
703
|
const hashes = msg.destHashes.filter((h) => typeof h === 'string' && /^[0-9a-f]{16}$/.test(h));
|
|
310
704
|
registerClient(ws, hashes);
|
|
311
|
-
// Register push token/subscription if provided
|
|
312
705
|
if (msg.pushPlatform === 'webpush' && typeof msg.pushSubscription === 'string') {
|
|
313
|
-
|
|
314
|
-
token: msg.pushSubscription,
|
|
706
|
+
registerPushTokens(hashes, {
|
|
707
|
+
token: msg.pushSubscription,
|
|
315
708
|
platform: 'webpush',
|
|
316
709
|
pushSubscription: msg.pushSubscription,
|
|
317
|
-
};
|
|
318
|
-
registerPushTokens(hashes, reg);
|
|
710
|
+
});
|
|
319
711
|
}
|
|
320
712
|
else if (typeof msg.pushToken === 'string' && msg.pushToken &&
|
|
321
713
|
(msg.pushPlatform === 'apns' || msg.pushPlatform === 'fcm')) {
|
|
322
|
-
|
|
714
|
+
registerPushTokens(hashes, {
|
|
323
715
|
token: msg.pushToken,
|
|
324
716
|
platform: msg.pushPlatform,
|
|
325
717
|
...(typeof msg.pushTopic === 'string' && msg.pushTopic ? { topic: msg.pushTopic } : {}),
|
|
326
|
-
};
|
|
327
|
-
registerPushTokens(hashes, reg);
|
|
718
|
+
});
|
|
328
719
|
}
|
|
329
720
|
deliverQueuedBlobs(ws, hashes);
|
|
330
721
|
return;
|
|
@@ -340,12 +731,8 @@ function handleWebSocketConnection(ws) {
|
|
|
340
731
|
// Not JSON or malformed — ignore
|
|
341
732
|
}
|
|
342
733
|
});
|
|
343
|
-
ws.on('close', () =>
|
|
344
|
-
|
|
345
|
-
});
|
|
346
|
-
ws.on('error', () => {
|
|
347
|
-
deregisterClient(ws);
|
|
348
|
-
});
|
|
734
|
+
ws.on('close', () => deregisterClient(ws));
|
|
735
|
+
ws.on('error', () => deregisterClient(ws));
|
|
349
736
|
}
|
|
350
737
|
// ============================================================
|
|
351
738
|
// HTTP handler — health check and prekey directory
|
|
@@ -366,6 +753,7 @@ function sendJson(res, status, body) {
|
|
|
366
753
|
'Access-Control-Allow-Origin': '*',
|
|
367
754
|
});
|
|
368
755
|
res.end(payload);
|
|
756
|
+
recordHttpStatus(status);
|
|
369
757
|
}
|
|
370
758
|
async function handleHttp(req, res) {
|
|
371
759
|
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
@@ -374,31 +762,122 @@ async function handleHttp(req, res) {
|
|
|
374
762
|
if (method === 'OPTIONS') {
|
|
375
763
|
res.writeHead(204, {
|
|
376
764
|
'Access-Control-Allow-Origin': '*',
|
|
377
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
378
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
765
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
|
766
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
379
767
|
});
|
|
380
768
|
res.end();
|
|
381
769
|
return;
|
|
382
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
|
+
}
|
|
383
801
|
// Health check
|
|
384
802
|
if (url.pathname === '/health' && method === 'GET') {
|
|
385
803
|
sendJson(res, 200, {
|
|
386
804
|
status: 'ok',
|
|
387
805
|
clients: clientsByHash.size,
|
|
388
|
-
storedBlobs:
|
|
389
|
-
prekeyEntries:
|
|
390
|
-
pushRegistrations:
|
|
391
|
-
mediaEntries:
|
|
806
|
+
storedBlobs: stmts.countBlobs.get().cnt,
|
|
807
|
+
prekeyEntries: stmts.countPrekeys.get().cnt,
|
|
808
|
+
pushRegistrations: stmts.countPush.get().cnt,
|
|
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,
|
|
814
|
+
});
|
|
815
|
+
return;
|
|
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': '*',
|
|
392
871
|
});
|
|
872
|
+
res.end(body);
|
|
873
|
+
recordHttpStatus(200);
|
|
393
874
|
return;
|
|
394
875
|
}
|
|
395
876
|
// Register prekey bundle
|
|
396
|
-
// POST /directory { namespace, publicKey, bundle }
|
|
877
|
+
// POST /directory { namespace, publicKey, bundle, username? }
|
|
397
878
|
if (url.pathname === '/directory' && method === 'POST') {
|
|
398
|
-
if (
|
|
399
|
-
sendJson(res, 429, { error: 'Too many requests' });
|
|
879
|
+
if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
|
|
400
880
|
return;
|
|
401
|
-
}
|
|
402
881
|
let body;
|
|
403
882
|
try {
|
|
404
883
|
body = JSON.parse(await parseBody(req));
|
|
@@ -414,35 +893,254 @@ async function handleHttp(req, res) {
|
|
|
414
893
|
sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, bundle' });
|
|
415
894
|
return;
|
|
416
895
|
}
|
|
417
|
-
|
|
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
|
+
}
|
|
418
934
|
sendJson(res, 200, { ok: true });
|
|
419
935
|
return;
|
|
420
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
|
+
}
|
|
421
991
|
// Lookup prekey bundle
|
|
422
992
|
// GET /directory?namespace=&publicKey=
|
|
993
|
+
// GET /directory?namespace=&username=alice
|
|
423
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;
|
|
424
1124
|
const namespace = url.searchParams.get('namespace');
|
|
425
1125
|
const publicKey = url.searchParams.get('publicKey');
|
|
426
1126
|
if (!namespace || !publicKey) {
|
|
427
1127
|
sendJson(res, 400, { error: 'Missing query params: namespace, publicKey' });
|
|
428
1128
|
return;
|
|
429
1129
|
}
|
|
430
|
-
const
|
|
431
|
-
if (!
|
|
432
|
-
sendJson(res, 404, { error: '
|
|
1130
|
+
const opk = claimOpkTx(directoryKey(namespace, publicKey));
|
|
1131
|
+
if (!opk) {
|
|
1132
|
+
sendJson(res, 404, { error: 'No one-time pre-keys available' });
|
|
433
1133
|
return;
|
|
434
1134
|
}
|
|
435
|
-
sendJson(res, 200, {
|
|
1135
|
+
sendJson(res, 200, { opk });
|
|
436
1136
|
return;
|
|
437
1137
|
}
|
|
438
1138
|
// Upload encrypted media blob
|
|
439
1139
|
// POST /media (binary body, Content-Length required)
|
|
440
1140
|
// Returns: { id, url, expiresAt }
|
|
441
1141
|
if (url.pathname === '/media' && method === 'POST') {
|
|
442
|
-
if (
|
|
443
|
-
sendJson(res, 429, { error: 'Too many requests' });
|
|
1142
|
+
if (rateLimited(req, res, 'media', RATE_LIMIT_MEDIA))
|
|
444
1143
|
return;
|
|
445
|
-
}
|
|
446
1144
|
const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
|
|
447
1145
|
if (contentLength > MAX_MEDIA_SIZE) {
|
|
448
1146
|
sendJson(res, 413, { error: `Payload too large (max ${MAX_MEDIA_SIZE} bytes)` });
|
|
@@ -479,6 +1177,8 @@ async function handleHttp(req, res) {
|
|
|
479
1177
|
// GET /media/:id
|
|
480
1178
|
const mediaMatch = url.pathname.match(/^\/media\/([0-9a-f]{32})$/);
|
|
481
1179
|
if (mediaMatch && method === 'GET') {
|
|
1180
|
+
if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
|
|
1181
|
+
return;
|
|
482
1182
|
const data = fetchMedia(mediaMatch[1]);
|
|
483
1183
|
if (!data) {
|
|
484
1184
|
sendJson(res, 404, { error: 'Media not found or expired' });
|
|
@@ -493,6 +1193,78 @@ async function handleHttp(req, res) {
|
|
|
493
1193
|
res.end(data);
|
|
494
1194
|
return;
|
|
495
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
|
+
}
|
|
496
1268
|
sendJson(res, 404, { error: 'Not found' });
|
|
497
1269
|
}
|
|
498
1270
|
// ============================================================
|
|
@@ -504,11 +1276,89 @@ const httpServer = http.createServer((req, res) => {
|
|
|
504
1276
|
res.writeHead(500).end();
|
|
505
1277
|
});
|
|
506
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
|
+
}
|
|
507
1348
|
const wss = new WebSocketServer({ noServer: true });
|
|
508
1349
|
wss.on('connection', (ws) => {
|
|
1350
|
+
metrics.websocketConnectionsTotal++;
|
|
509
1351
|
handleWebSocketConnection(ws);
|
|
510
1352
|
});
|
|
511
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
|
+
}
|
|
512
1362
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
513
1363
|
wss.emit('connection', ws, req);
|
|
514
1364
|
});
|
|
@@ -519,13 +1369,19 @@ const pruneInterval = setInterval(() => {
|
|
|
519
1369
|
pruneExpiredMedia();
|
|
520
1370
|
pruneRateLimitState();
|
|
521
1371
|
}, PRUNE_INTERVAL_MS);
|
|
522
|
-
pruneInterval.unref();
|
|
523
|
-
|
|
1372
|
+
pruneInterval.unref();
|
|
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, () => {
|
|
524
1377
|
console.log(`MeshWhisper Node listening on port ${PORT}`);
|
|
525
1378
|
console.log(` Relay: ws://localhost:${PORT}`);
|
|
526
1379
|
console.log(` Directory: http://localhost:${PORT}/directory`);
|
|
1380
|
+
console.log(` OPKs: http://localhost:${PORT}/opks`);
|
|
527
1381
|
console.log(` Media: http://localhost:${PORT}/media`);
|
|
1382
|
+
console.log(` Archive: http://localhost:${PORT}/archive/:peerId`);
|
|
528
1383
|
console.log(` Health: http://localhost:${PORT}/health`);
|
|
1384
|
+
console.log(` Database: ${DB_PATH}`);
|
|
529
1385
|
console.log(` Blob TTL: ${BLOB_TTL_HOURS}h`);
|
|
530
1386
|
console.log(` Media TTL: ${MEDIA_TTL_HOURS}h (max ${MAX_MEDIA_SIZE / (1024 * 1024)}MB per file)`);
|
|
531
1387
|
console.log(` Base URL: ${BASE_URL || '(inferred from Host header — set BASE_URL in production)'}`);
|
|
@@ -539,8 +1395,14 @@ function shutdown() {
|
|
|
539
1395
|
clearInterval(pruneInterval);
|
|
540
1396
|
wss.clients.forEach((ws) => ws.close(1001, 'Node shutting down'));
|
|
541
1397
|
wss.close();
|
|
542
|
-
httpServer.close(() =>
|
|
543
|
-
|
|
1398
|
+
httpServer.close(() => {
|
|
1399
|
+
db.close();
|
|
1400
|
+
process.exit(0);
|
|
1401
|
+
});
|
|
1402
|
+
setTimeout(() => {
|
|
1403
|
+
db.close();
|
|
1404
|
+
process.exit(1);
|
|
1405
|
+
}, 3000).unref();
|
|
544
1406
|
}
|
|
545
1407
|
process.on('SIGINT', shutdown);
|
|
546
1408
|
process.on('SIGTERM', shutdown);
|