@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/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @meshwhisper/node
2
+
3
+ The [MeshWhisper](https://github.com/twotwoonethree/meshwhisper) relay node — packet relay, store-and-forward, push-notification forwarding, encrypted media storage, encrypted archive storage, username/prekey directory, and relay-to-relay federation, in a single binary.
4
+
5
+ The node never holds a decryption key. Everything it relays, stores, or queues is opaque ciphertext; clients encrypt on-device with PQXDH + Double Ratchet via [`@meshwhisper/sdk`](https://www.npmjs.com/package/@meshwhisper/sdk).
6
+
7
+ ## Quickstart
8
+
9
+ The easiest path is the CLI, which writes a Docker Compose deployment around this package:
10
+
11
+ ```bash
12
+ npx @meshwhisper/cli init
13
+ ```
14
+
15
+ Or run it directly:
16
+
17
+ ```bash
18
+ npm install -g @meshwhisper/node
19
+ BASE_URL=https://relay.myapp.com DB_PATH=./meshwhisper.db meshwhisper-node
20
+ ```
21
+
22
+ Listens on port 8080 (`PORT` to change). Put TLS in front of it (Caddy: `reverse_proxy localhost:8080`). Verify with `curl https://relay.myapp.com/health` or `npx @meshwhisper/cli doctor`.
23
+
24
+ ## Key environment variables
25
+
26
+ | Variable | Default | Description |
27
+ |---|---|---|
28
+ | `PORT` | `8080` | HTTP/WebSocket listen port |
29
+ | `BASE_URL` | — | Public URL; **required** for media download links to be reachable |
30
+ | `DB_PATH` | `./meshwhisper.db` | SQLite database location |
31
+ | `BLOB_TTL_HOURS` | `720` | How long queued messages wait for offline recipients (30 days) |
32
+ | `MEDIA_TTL_HOURS` | `168` | Encrypted media retention |
33
+ | `PUSH_WEBHOOK_URL` | — | `@meshwhisper/push-service` endpoint for offline wake signals |
34
+ | `TRUST_PROXY` | unset | Set to `1` behind a reverse proxy so rate limiting sees real client IPs |
35
+ | `FEDERATION_MODE` | off | `open` joins the relay mesh — your node forwards packets for other relays and they for yours; `allowlist` for explicit peering |
36
+
37
+ Full reference, including federation, backups, and metrics: [self-hosting guide](https://github.com/twotwoonethree/meshwhisper/blob/main/docs/self-hosting.md).
38
+
39
+ ## Operational features
40
+
41
+ - Per-IP rate limiting on every endpoint
42
+ - Prometheus metrics at `/metrics`, health at `/health`
43
+ - Hot backup via bundled sqlite: `sqlite3 meshwhisper.db ".backup backup.db"`
44
+ - Federation wire protocol specified in [docs/federation.md](https://github.com/twotwoonethree/meshwhisper/blob/main/docs/federation.md)
45
+
46
+ MIT
@@ -0,0 +1,99 @@
1
+ import * as nodeCrypto from 'node:crypto';
2
+ import type { IncomingMessage } from 'node:http';
3
+ export declare const FEDERATION_SUBPROTOCOL = "meshwhisper-federation.v1";
4
+ /** Admission policy. 'open' accepts any peer that completes the handshake
5
+ * (Tor-middle-node posture — the project's recommended setting once a
6
+ * mesh exists); 'allowlist' requires the pubkey to be pre-approved. */
7
+ export type FederationMode = 'allowlist' | 'open';
8
+ export interface FederationKey {
9
+ publicKeyHex: string;
10
+ privateKey: nodeCrypto.KeyObject;
11
+ }
12
+ /**
13
+ * Load the node's federation keypair from `keyPath`, generating and
14
+ * persisting a fresh one if the file doesn't exist. Stored shape:
15
+ * { publicKeyHex, privateKeyPkcs8Base64 }.
16
+ */
17
+ export declare function loadOrCreateFederationKey(keyPath: string): FederationKey;
18
+ export interface PeerConfig {
19
+ pubkey: string;
20
+ /** Present = this node initiates outbound connections to the peer. */
21
+ url?: string;
22
+ }
23
+ /** Load { peers: [...] } from `peersPath`. Missing file = no peers = federation dormant. */
24
+ export declare function loadPeersConfig(peersPath: string): PeerConfig[];
25
+ /**
26
+ * Load the reactive blocklist: { "blocked": ["<pubkeyhex>", ...] }.
27
+ * Checked at handshake time — a blocked pubkey is rejected regardless of
28
+ * mode. Evicting an already-connected peer requires a restart in v1.
29
+ */
30
+ export declare function loadBlocklist(blocklistPath: string): Set<string>;
31
+ export declare function buildHandshakeCanonical(initiatorPubkeyHex: string, responderPubkeyHex: string, initiatorNonceHex: string, responderNonceHex: string): Buffer;
32
+ type LocalOutcome = 'delivered' | 'stored' | 'unknown';
33
+ export interface FederationStats {
34
+ peersConfigured: number;
35
+ peersConnected: number;
36
+ forwardsSentTotal: number;
37
+ forwardsReceivedTotal: number;
38
+ deliveredLocallyTotal: number;
39
+ storedLocallyTotal: number;
40
+ forwardedOnwardTotal: number;
41
+ dropsDuplicateTotal: number;
42
+ dropsTtlTotal: number;
43
+ dropsRateLimitedTotal: number;
44
+ handshakeFailuresTotal: number;
45
+ handshakeRejectionsBlockedTotal: number;
46
+ }
47
+ export declare class FederationManager {
48
+ private readonly key;
49
+ private readonly mode;
50
+ private readonly allowedPubkeys;
51
+ private readonly blockedPubkeys;
52
+ private readonly maxPeers;
53
+ private readonly rateLimitPerMin;
54
+ private readonly peers;
55
+ private readonly cache;
56
+ private readonly classifyLocal;
57
+ private readonly wss;
58
+ private stopped;
59
+ readonly stats: FederationStats;
60
+ constructor(opts: {
61
+ key: FederationKey;
62
+ peers: PeerConfig[];
63
+ classifyLocal: (packet: Uint8Array) => LocalOutcome;
64
+ /** Admission policy. Default 'allowlist' (v1 behavior). */
65
+ mode?: FederationMode;
66
+ /** Pubkeys rejected at handshake regardless of mode. */
67
+ blockedPubkeys?: Set<string>;
68
+ /** Open-mode cap on total simultaneously-tracked peers (configured +
69
+ * dynamically admitted). Handshakes beyond the cap are rejected. */
70
+ maxPeers?: number;
71
+ /** Per-peer PacketForward frames accepted per minute; excess dropped. */
72
+ rateLimitPerMin?: number;
73
+ });
74
+ private newPeerState;
75
+ /** Dial every peer that has a url. Inbound peers connect to us instead. */
76
+ start(): void;
77
+ stop(): void;
78
+ connectedPeerCount(): number;
79
+ /** Route an HTTP upgrade with the federation subprotocol into the manager. */
80
+ handleUpgrade(req: IncomingMessage, socket: import('node:stream').Duplex, head: Buffer): void;
81
+ /**
82
+ * Forward a locally-received packet (from one of our own clients)
83
+ * whose destHash we have no local knowledge of. Mints a fresh
84
+ * packetId, forwardCount = 0, fans out to every established peer.
85
+ */
86
+ forwardFromLocal(packet: Uint8Array): void;
87
+ private fanOut;
88
+ private dial;
89
+ private scheduleReconnect;
90
+ private teardownPeer;
91
+ /** One-shot wait for the next message on a socket (handshake phases). */
92
+ private nextMessage;
93
+ private runInitiatorHandshake;
94
+ private runResponderHandshake;
95
+ private establishPeer;
96
+ private handleFrame;
97
+ }
98
+ export {};
99
+ //# sourceMappingURL=federation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"federation.d.ts","sourceRoot":"","sources":["../src/federation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,539 @@
1
+ // ============================================================
2
+ // MeshWhisper Node — Federation (node-to-node packet forwarding)
3
+ //
4
+ // Implements docs/federation.md v1:
5
+ // - Pairwise peering between explicitly allow-listed nodes
6
+ // - Mutual Ed25519 handshake over a WebSocket with the
7
+ // `meshwhisper-federation.v1` subprotocol
8
+ // - Length-prefixed binary frames: PacketForward + Heartbeat
9
+ // - Loop prevention via a packet-id LRU
10
+ // - TTL (hop-count) exhaustion
11
+ // - Reconnect with exponential backoff for outbound peers
12
+ //
13
+ // The module is deliberately self-contained: index.ts hands it an
14
+ // allow-list + keypair + a single `classifyLocal` callback that
15
+ // answers "can this packet be delivered or stored locally?", and the
16
+ // module handles everything else. The relay's existing client-relay
17
+ // path is untouched.
18
+ // ============================================================
19
+ import { WebSocketServer, WebSocket } from 'ws';
20
+ import * as nodeCrypto from 'node:crypto';
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ export const FEDERATION_SUBPROTOCOL = 'meshwhisper-federation.v1';
24
+ // ---- Wire constants (docs/federation.md "Forwarding wire format") ----
25
+ const WIRE_VERSION = 0x01;
26
+ const FRAME_PACKET_FORWARD = 0x01;
27
+ const FRAME_HEARTBEAT = 0x02;
28
+ const MAX_HOPS = parseInt(process.env.FEDERATION_MAX_HOPS ?? '3', 10);
29
+ const MAX_FRAME_BODY = 8192;
30
+ const HEARTBEAT_INTERVAL_MS = 30_000;
31
+ const HEARTBEAT_TIMEOUT_MS = 90_000;
32
+ const PACKET_ID_CACHE_SIZE = 1024;
33
+ const PACKET_ID_TTL_MS = 60_000;
34
+ const RECONNECT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000];
35
+ // DER wrappers so node:crypto can ingest raw Ed25519 key bytes.
36
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
37
+ /**
38
+ * Load the node's federation keypair from `keyPath`, generating and
39
+ * persisting a fresh one if the file doesn't exist. Stored shape:
40
+ * { publicKeyHex, privateKeyPkcs8Base64 }.
41
+ */
42
+ export function loadOrCreateFederationKey(keyPath) {
43
+ if (fs.existsSync(keyPath)) {
44
+ const raw = JSON.parse(fs.readFileSync(keyPath, 'utf8'));
45
+ const privateKey = nodeCrypto.createPrivateKey({
46
+ key: Buffer.from(raw.privateKeyPkcs8Base64, 'base64'),
47
+ format: 'der',
48
+ type: 'pkcs8',
49
+ });
50
+ return { publicKeyHex: raw.publicKeyHex, privateKey };
51
+ }
52
+ const { publicKey, privateKey } = nodeCrypto.generateKeyPairSync('ed25519');
53
+ const spki = publicKey.export({ format: 'der', type: 'spki' });
54
+ const publicKeyHex = spki.subarray(spki.length - 32).toString('hex');
55
+ const pkcs8 = privateKey.export({ format: 'der', type: 'pkcs8' });
56
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
57
+ fs.writeFileSync(keyPath, JSON.stringify({
58
+ publicKeyHex,
59
+ privateKeyPkcs8Base64: pkcs8.toString('base64'),
60
+ }, null, 2), { mode: 0o600 });
61
+ return { publicKeyHex, privateKey };
62
+ }
63
+ /** Load { peers: [...] } from `peersPath`. Missing file = no peers = federation dormant. */
64
+ export function loadPeersConfig(peersPath) {
65
+ if (!fs.existsSync(peersPath))
66
+ return [];
67
+ try {
68
+ const raw = JSON.parse(fs.readFileSync(peersPath, 'utf8'));
69
+ return (raw.peers ?? []).filter((p) => typeof p?.pubkey === 'string' && /^[0-9a-f]{64}$/.test(p.pubkey));
70
+ }
71
+ catch {
72
+ console.error(`[federation] malformed peers file at ${peersPath} — federation dormant`);
73
+ return [];
74
+ }
75
+ }
76
+ /**
77
+ * Load the reactive blocklist: { "blocked": ["<pubkeyhex>", ...] }.
78
+ * Checked at handshake time — a blocked pubkey is rejected regardless of
79
+ * mode. Evicting an already-connected peer requires a restart in v1.
80
+ */
81
+ export function loadBlocklist(blocklistPath) {
82
+ if (!fs.existsSync(blocklistPath))
83
+ return new Set();
84
+ try {
85
+ const raw = JSON.parse(fs.readFileSync(blocklistPath, 'utf8'));
86
+ return new Set((raw.blocked ?? []).filter((p) => /^[0-9a-f]{64}$/.test(p)));
87
+ }
88
+ catch {
89
+ console.error(`[federation] malformed blocklist at ${blocklistPath} — ignoring`);
90
+ return new Set();
91
+ }
92
+ }
93
+ // ---- Canonical handshake message ----
94
+ export function buildHandshakeCanonical(initiatorPubkeyHex, responderPubkeyHex, initiatorNonceHex, responderNonceHex) {
95
+ return Buffer.from([
96
+ FEDERATION_SUBPROTOCOL,
97
+ initiatorPubkeyHex,
98
+ responderPubkeyHex,
99
+ initiatorNonceHex,
100
+ responderNonceHex,
101
+ ].join('\n'), 'utf8');
102
+ }
103
+ function verifyWithRawPubkey(pubkeyHex, message, signature) {
104
+ if (signature.length !== 64)
105
+ return false;
106
+ let keyObj;
107
+ try {
108
+ keyObj = nodeCrypto.createPublicKey({
109
+ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(pubkeyHex, 'hex')]),
110
+ format: 'der',
111
+ type: 'spki',
112
+ });
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ try {
118
+ return nodeCrypto.verify(null, message, keyObj, signature);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ function encodeHello(h) {
125
+ const buf = Buffer.alloc(1 + 32 + 16 + 4);
126
+ buf.writeUInt8(h.version, 0);
127
+ Buffer.from(h.pubkeyHex, 'hex').copy(buf, 1);
128
+ Buffer.from(h.nonceHex, 'hex').copy(buf, 33);
129
+ buf.writeUInt32BE(h.capabilities, 49);
130
+ return buf;
131
+ }
132
+ function decodeHello(buf) {
133
+ if (buf.length !== 53)
134
+ return null;
135
+ return {
136
+ version: buf.readUInt8(0),
137
+ pubkeyHex: buf.subarray(1, 33).toString('hex'),
138
+ nonceHex: buf.subarray(33, 49).toString('hex'),
139
+ capabilities: buf.readUInt32BE(49),
140
+ };
141
+ }
142
+ // ---- Data frame codec ----
143
+ function encodeFrame(frameType, body) {
144
+ const out = Buffer.alloc(1 + 4 + body.length);
145
+ out.writeUInt8(frameType, 0);
146
+ out.writeUInt32BE(body.length, 1);
147
+ body.copy(out, 5);
148
+ return out;
149
+ }
150
+ function decodeFrame(buf) {
151
+ if (buf.length < 5)
152
+ return null;
153
+ const frameType = buf.readUInt8(0);
154
+ const length = buf.readUInt32BE(1);
155
+ if (length > MAX_FRAME_BODY)
156
+ return null;
157
+ if (buf.length !== 5 + length)
158
+ return null;
159
+ return { frameType, body: buf.subarray(5) };
160
+ }
161
+ // ---- Packet-id LRU (loop prevention) ----
162
+ class PacketIdCache {
163
+ entries = new Map(); // id → insertedAt
164
+ /** Returns true if the id was already present (within TTL). Inserts otherwise. */
165
+ checkAndInsert(idHex) {
166
+ const now = Date.now();
167
+ const existing = this.entries.get(idHex);
168
+ if (existing !== undefined && now - existing < PACKET_ID_TTL_MS) {
169
+ return true;
170
+ }
171
+ // Refresh / insert. Evict oldest beyond capacity (Map preserves insertion order).
172
+ this.entries.delete(idHex);
173
+ this.entries.set(idHex, now);
174
+ while (this.entries.size > PACKET_ID_CACHE_SIZE) {
175
+ const oldest = this.entries.keys().next().value;
176
+ this.entries.delete(oldest);
177
+ }
178
+ return false;
179
+ }
180
+ }
181
+ // ---- Manager ----
182
+ export class FederationManager {
183
+ key;
184
+ mode;
185
+ allowedPubkeys;
186
+ blockedPubkeys;
187
+ maxPeers;
188
+ rateLimitPerMin;
189
+ peers = new Map(); // pubkeyHex → state
190
+ cache = new PacketIdCache();
191
+ classifyLocal;
192
+ wss;
193
+ stopped = false;
194
+ stats = {
195
+ peersConfigured: 0,
196
+ peersConnected: 0,
197
+ forwardsSentTotal: 0,
198
+ forwardsReceivedTotal: 0,
199
+ deliveredLocallyTotal: 0,
200
+ storedLocallyTotal: 0,
201
+ forwardedOnwardTotal: 0,
202
+ dropsDuplicateTotal: 0,
203
+ dropsTtlTotal: 0,
204
+ dropsRateLimitedTotal: 0,
205
+ handshakeFailuresTotal: 0,
206
+ handshakeRejectionsBlockedTotal: 0,
207
+ };
208
+ constructor(opts) {
209
+ this.key = opts.key;
210
+ this.classifyLocal = opts.classifyLocal;
211
+ this.mode = opts.mode ?? 'allowlist';
212
+ this.blockedPubkeys = opts.blockedPubkeys ?? new Set();
213
+ this.maxPeers = opts.maxPeers ?? 64;
214
+ this.rateLimitPerMin = opts.rateLimitPerMin ?? 6000; // ≈100 frames/sec
215
+ this.allowedPubkeys = new Set(opts.peers.map((p) => p.pubkey));
216
+ for (const p of opts.peers) {
217
+ this.peers.set(p.pubkey, this.newPeerState(p, false));
218
+ }
219
+ this.stats.peersConfigured = opts.peers.length;
220
+ this.wss = new WebSocketServer({
221
+ noServer: true,
222
+ handleProtocols: (protocols) => protocols.has(FEDERATION_SUBPROTOCOL) ? FEDERATION_SUBPROTOCOL : false,
223
+ });
224
+ }
225
+ newPeerState(config, dynamic) {
226
+ return {
227
+ config,
228
+ ws: null,
229
+ established: false,
230
+ reconnectAttempt: 0,
231
+ reconnectTimer: null,
232
+ heartbeatTimer: null,
233
+ lastFrameAt: 0,
234
+ dynamic,
235
+ frameWindow: { count: 0, windowStart: 0 },
236
+ };
237
+ }
238
+ /** Dial every peer that has a url. Inbound peers connect to us instead. */
239
+ start() {
240
+ for (const state of this.peers.values()) {
241
+ if (state.config.url)
242
+ this.dial(state);
243
+ }
244
+ }
245
+ stop() {
246
+ this.stopped = true;
247
+ for (const state of this.peers.values()) {
248
+ if (state.reconnectTimer)
249
+ clearTimeout(state.reconnectTimer);
250
+ if (state.heartbeatTimer)
251
+ clearInterval(state.heartbeatTimer);
252
+ state.ws?.close();
253
+ }
254
+ }
255
+ connectedPeerCount() {
256
+ let n = 0;
257
+ for (const s of this.peers.values())
258
+ if (s.established)
259
+ n++;
260
+ return n;
261
+ }
262
+ /** Route an HTTP upgrade with the federation subprotocol into the manager. */
263
+ handleUpgrade(req, socket, head) {
264
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
265
+ this.runResponderHandshake(ws).catch(() => {
266
+ this.stats.handshakeFailuresTotal++;
267
+ try {
268
+ ws.close();
269
+ }
270
+ catch { /* already closed */ }
271
+ });
272
+ });
273
+ }
274
+ /**
275
+ * Forward a locally-received packet (from one of our own clients)
276
+ * whose destHash we have no local knowledge of. Mints a fresh
277
+ * packetId, forwardCount = 0, fans out to every established peer.
278
+ */
279
+ forwardFromLocal(packet) {
280
+ if (packet.byteLength > MAX_FRAME_BODY - 17)
281
+ return; // can't fit in a frame body
282
+ const packetId = nodeCrypto.randomBytes(16);
283
+ // Insert into our own cache so a loop back to us is dropped.
284
+ this.cache.checkAndInsert(packetId.toString('hex'));
285
+ this.fanOut(packetId, 0, Buffer.from(packet), null);
286
+ }
287
+ // ---- internals ----
288
+ fanOut(packetId, forwardCount, packet, excludePubkey) {
289
+ const body = Buffer.alloc(17 + packet.length);
290
+ packetId.copy(body, 0);
291
+ body.writeUInt8(forwardCount, 16);
292
+ packet.copy(body, 17);
293
+ const frame = encodeFrame(FRAME_PACKET_FORWARD, body);
294
+ for (const [pubkey, state] of this.peers) {
295
+ if (pubkey === excludePubkey)
296
+ continue;
297
+ if (!state.established || !state.ws || state.ws.readyState !== WebSocket.OPEN)
298
+ continue;
299
+ try {
300
+ state.ws.send(frame, { binary: true });
301
+ this.stats.forwardsSentTotal++;
302
+ }
303
+ catch { /* dead socket — heartbeat will reap */ }
304
+ }
305
+ }
306
+ dial(state) {
307
+ if (this.stopped || !state.config.url)
308
+ return;
309
+ const ws = new WebSocket(state.config.url, FEDERATION_SUBPROTOCOL);
310
+ state.ws = ws;
311
+ ws.on('open', () => {
312
+ this.runInitiatorHandshake(ws, state).catch(() => {
313
+ this.stats.handshakeFailuresTotal++;
314
+ try {
315
+ ws.close();
316
+ }
317
+ catch { /* already closed */ }
318
+ });
319
+ });
320
+ ws.on('error', () => { });
321
+ ws.on('close', () => {
322
+ this.teardownPeer(state);
323
+ this.scheduleReconnect(state);
324
+ });
325
+ }
326
+ scheduleReconnect(state) {
327
+ if (this.stopped || !state.config.url || state.reconnectTimer)
328
+ return;
329
+ const delay = RECONNECT_BACKOFF_MS[Math.min(state.reconnectAttempt, RECONNECT_BACKOFF_MS.length - 1)];
330
+ state.reconnectAttempt++;
331
+ state.reconnectTimer = setTimeout(() => {
332
+ state.reconnectTimer = null;
333
+ this.dial(state);
334
+ }, delay);
335
+ }
336
+ teardownPeer(state) {
337
+ state.established = false;
338
+ state.ws = null;
339
+ if (state.heartbeatTimer) {
340
+ clearInterval(state.heartbeatTimer);
341
+ state.heartbeatTimer = null;
342
+ }
343
+ // Dynamically-admitted peers leave the map entirely on disconnect so
344
+ // the open-mode cap frees up. We hold no reconnect duty for them —
345
+ // they dial us again whenever they want back in.
346
+ if (state.dynamic) {
347
+ this.peers.delete(state.config.pubkey);
348
+ }
349
+ this.stats.peersConnected = this.connectedPeerCount();
350
+ }
351
+ /** One-shot wait for the next message on a socket (handshake phases). */
352
+ nextMessage(ws, timeoutMs = 10_000) {
353
+ return new Promise((resolve, reject) => {
354
+ const timer = setTimeout(() => {
355
+ cleanup();
356
+ reject(new Error('handshake timeout'));
357
+ }, timeoutMs);
358
+ const onMessage = (raw) => {
359
+ cleanup();
360
+ resolve(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
361
+ };
362
+ const onClose = () => {
363
+ cleanup();
364
+ reject(new Error('closed during handshake'));
365
+ };
366
+ const cleanup = () => {
367
+ clearTimeout(timer);
368
+ ws.off('message', onMessage);
369
+ ws.off('close', onClose);
370
+ };
371
+ ws.once('message', onMessage);
372
+ ws.once('close', onClose);
373
+ });
374
+ }
375
+ async runInitiatorHandshake(ws, state) {
376
+ const myNonce = nodeCrypto.randomBytes(16);
377
+ ws.send(encodeHello({
378
+ version: WIRE_VERSION,
379
+ pubkeyHex: this.key.publicKeyHex,
380
+ nonceHex: myNonce.toString('hex'),
381
+ capabilities: 0,
382
+ }), { binary: true });
383
+ const serverHello = decodeHello(await this.nextMessage(ws));
384
+ if (!serverHello)
385
+ throw new Error('malformed ServerHello');
386
+ if (serverHello.version !== WIRE_VERSION)
387
+ throw new Error('peer rejected handshake');
388
+ if (serverHello.pubkeyHex !== state.config.pubkey)
389
+ throw new Error('peer pubkey mismatch');
390
+ const canonical = buildHandshakeCanonical(this.key.publicKeyHex, serverHello.pubkeyHex, myNonce.toString('hex'), serverHello.nonceHex);
391
+ ws.send(nodeCrypto.sign(null, canonical, this.key.privateKey), { binary: true });
392
+ const serverSig = await this.nextMessage(ws);
393
+ if (!verifyWithRawPubkey(serverHello.pubkeyHex, canonical, serverSig)) {
394
+ throw new Error('peer signature invalid');
395
+ }
396
+ state.reconnectAttempt = 0;
397
+ this.establishPeer(state, ws);
398
+ }
399
+ async runResponderHandshake(ws) {
400
+ const clientHello = decodeHello(await this.nextMessage(ws));
401
+ if (!clientHello)
402
+ throw new Error('malformed ClientHello');
403
+ const reject = () => {
404
+ ws.send(encodeHello({
405
+ version: 0x00,
406
+ pubkeyHex: this.key.publicKeyHex,
407
+ nonceHex: '0'.repeat(32),
408
+ capabilities: 0,
409
+ }), { binary: true });
410
+ throw new Error('handshake rejected');
411
+ };
412
+ if (clientHello.version !== WIRE_VERSION)
413
+ reject();
414
+ if (this.blockedPubkeys.has(clientHello.pubkeyHex)) {
415
+ this.stats.handshakeRejectionsBlockedTotal++;
416
+ reject();
417
+ }
418
+ if (this.mode === 'allowlist' && !this.allowedPubkeys.has(clientHello.pubkeyHex))
419
+ reject();
420
+ if (this.mode === 'open' &&
421
+ !this.peers.has(clientHello.pubkeyHex) &&
422
+ this.peers.size >= this.maxPeers)
423
+ reject();
424
+ const myNonce = nodeCrypto.randomBytes(16);
425
+ ws.send(encodeHello({
426
+ version: WIRE_VERSION,
427
+ pubkeyHex: this.key.publicKeyHex,
428
+ nonceHex: myNonce.toString('hex'),
429
+ capabilities: 0,
430
+ }), { binary: true });
431
+ const canonical = buildHandshakeCanonical(clientHello.pubkeyHex, this.key.publicKeyHex, clientHello.nonceHex, myNonce.toString('hex'));
432
+ const clientSig = await this.nextMessage(ws);
433
+ if (!verifyWithRawPubkey(clientHello.pubkeyHex, canonical, clientSig)) {
434
+ throw new Error('initiator signature invalid');
435
+ }
436
+ ws.send(nodeCrypto.sign(null, canonical, this.key.privateKey), { binary: true });
437
+ let state = this.peers.get(clientHello.pubkeyHex);
438
+ if (!state) {
439
+ // Open mode: dynamically admit. (Unreachable under allowlist —
440
+ // the admission check above would have rejected.)
441
+ state = this.newPeerState({ pubkey: clientHello.pubkeyHex }, true);
442
+ this.peers.set(clientHello.pubkeyHex, state);
443
+ }
444
+ // If a stale connection exists (e.g. both sides dialed), prefer the new one.
445
+ if (state.ws && state.ws !== ws) {
446
+ try {
447
+ state.ws.close();
448
+ }
449
+ catch { /* ignore */ }
450
+ }
451
+ this.establishPeer(state, ws);
452
+ }
453
+ establishPeer(state, ws) {
454
+ state.ws = ws;
455
+ state.established = true;
456
+ state.lastFrameAt = Date.now();
457
+ this.stats.peersConnected = this.connectedPeerCount();
458
+ console.log(`[federation] peer ${state.config.pubkey.slice(0, 12)}… connected`);
459
+ ws.on('message', (raw) => {
460
+ state.lastFrameAt = Date.now();
461
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
462
+ this.handleFrame(buf, state);
463
+ });
464
+ ws.on('close', () => {
465
+ console.log(`[federation] peer ${state.config.pubkey.slice(0, 12)}… disconnected`);
466
+ this.teardownPeer(state);
467
+ this.scheduleReconnect(state);
468
+ });
469
+ state.heartbeatTimer = setInterval(() => {
470
+ if (Date.now() - state.lastFrameAt > HEARTBEAT_TIMEOUT_MS) {
471
+ try {
472
+ ws.close();
473
+ }
474
+ catch { /* close handler reaps */ }
475
+ return;
476
+ }
477
+ const body = Buffer.alloc(8);
478
+ body.writeBigInt64BE(BigInt(Date.now()), 0);
479
+ try {
480
+ ws.send(encodeFrame(FRAME_HEARTBEAT, body), { binary: true });
481
+ }
482
+ catch { /* reaped on close */ }
483
+ }, HEARTBEAT_INTERVAL_MS);
484
+ }
485
+ handleFrame(buf, fromState) {
486
+ const frame = decodeFrame(buf);
487
+ if (!frame) {
488
+ // Malformed or oversized — close per spec.
489
+ try {
490
+ fromState.ws?.close();
491
+ }
492
+ catch { /* ignore */ }
493
+ return;
494
+ }
495
+ if (frame.frameType === FRAME_HEARTBEAT)
496
+ return; // lastFrameAt already updated
497
+ if (frame.frameType !== FRAME_PACKET_FORWARD)
498
+ return; // unknown type — ignore (forward-compat)
499
+ if (frame.body.length < 17 + 31)
500
+ return; // packetId + forwardCount + minimum packet header
501
+ // Per-peer rate limiting — the abuse boundary in open mode. Sliding
502
+ // 60s window; excess frames are silently dropped (not a disconnect:
503
+ // legitimate bursts shouldn't sever the link).
504
+ const now = Date.now();
505
+ if (now - fromState.frameWindow.windowStart >= 60_000) {
506
+ fromState.frameWindow = { count: 0, windowStart: now };
507
+ }
508
+ fromState.frameWindow.count++;
509
+ if (fromState.frameWindow.count > this.rateLimitPerMin) {
510
+ this.stats.dropsRateLimitedTotal++;
511
+ return;
512
+ }
513
+ const packetIdHex = frame.body.subarray(0, 16).toString('hex');
514
+ const forwardCount = frame.body.readUInt8(16);
515
+ const packet = frame.body.subarray(17);
516
+ this.stats.forwardsReceivedTotal++;
517
+ if (this.cache.checkAndInsert(packetIdHex)) {
518
+ this.stats.dropsDuplicateTotal++;
519
+ return;
520
+ }
521
+ if (forwardCount >= MAX_HOPS) {
522
+ this.stats.dropsTtlTotal++;
523
+ return;
524
+ }
525
+ const outcome = this.classifyLocal(packet);
526
+ if (outcome === 'delivered') {
527
+ this.stats.deliveredLocallyTotal++;
528
+ return;
529
+ }
530
+ if (outcome === 'stored') {
531
+ this.stats.storedLocallyTotal++;
532
+ return;
533
+ }
534
+ // Unknown locally — forward onward, excluding the peer it came from.
535
+ this.stats.forwardedOnwardTotal++;
536
+ this.fanOut(frame.body.subarray(0, 16), forwardCount + 1, Buffer.from(packet), fromState.config.pubkey);
537
+ }
538
+ }
539
+ //# sourceMappingURL=federation.js.map