@camerontaylor/paseo-relay 0.12.0-fork.1 → 0.13.0-fork.2

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.
@@ -0,0 +1,650 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Endpoint security for relay-carried daemon sessions.
4
+ *
5
+ * This is the one entrypoint both endpoints use to secure a transport. It owns
6
+ * the handshake, record protection, counters, retries, termination, and the
7
+ * negotiated security mode. Relay adapters own routing and physical I/O;
8
+ * daemon admission owns credentials and authorization.
9
+ *
10
+ * Two modes exist during migration:
11
+ *
12
+ * - "protected": conforming Noise_NK_25519_ChaChaPoly_SHA256 bound to the
13
+ * daemon's static key, with authenticated record framing. Handshake
14
+ * messages travel byte-exact (base64) inside the existing `e2ee_hello` /
15
+ * `e2ee_ready` JSON envelopes under `protected`.
16
+ * - "legacy": the pre-existing ephemeral-static box construction in
17
+ * `encrypted-channel.ts`. It has no replay protection. Chosen only when the
18
+ * peer omits `protected` (an older peer) and policy allows it.
19
+ *
20
+ * A present but invalid `protected` field never falls back to legacy.
21
+ *
22
+ * See docs/relay-endpoint-security.md for the byte-level mapping.
23
+ */
24
+ import { EncryptedChannel, base64EncryptedWireByteLength, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
25
+ import { deriveSharedKey, exportPublicKey, generateKeyPair, importPublicKey, } from "./crypto.js";
26
+ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
27
+ import { createNoiseInitiator, createNoiseResponder, NoiseProtocolError, } from "./noise/index.js";
28
+ import { decodeProtectedRecord, encodeProtectedRecord, protectedRecordByteLength, } from "./protected-records.js";
29
+ /** Thrown by {@link openDaemonSecureChannel} when the handshake is refused; carries the close to send. */
30
+ export class SecureChannelRejectedError extends Error {
31
+ constructor(message, closeCode, closeReason) {
32
+ super(message);
33
+ this.closeCode = closeCode;
34
+ this.closeReason = closeReason;
35
+ this.name = "SecureChannelRejectedError";
36
+ }
37
+ }
38
+ export const PROTECTED_MODE_REQUIRED_CLOSE_CODE = 1008;
39
+ export const PROTECTED_MODE_REQUIRED_CLOSE_REASON = "protected endpoint security required";
40
+ /** Client-side close when a protected handshake fails. Browser-safe (4000-4999). */
41
+ export const PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE = 4002;
42
+ /**
43
+ * Client-side close when an inbound record fails authentication or framing.
44
+ * Browser WebSocket.close accepts only 1000 or 3000-4999, so a client must
45
+ * never try to send a 1011.
46
+ */
47
+ export const PROTECTED_RECORD_FAILED_CLOSE_CODE = 4003;
48
+ /** Daemon-side close for the same failures; the daemon runs on Node's ws. */
49
+ const DAEMON_RECORD_FAILURE_CLOSE_CODE = 1011;
50
+ const REHANDSHAKE_REJECTION_CODE = 1008;
51
+ const REHANDSHAKE_MISMATCH_CLOSE_REASON = "E2EE re-handshake transcript mismatch";
52
+ const NOISE_PROLOGUE = new TextEncoder().encode("paseo-relay-e2ee/1");
53
+ const PROTECTED_ENVELOPE_VERSION = 1;
54
+ const PARAMS_VERSION = 0x01;
55
+ const PARAM_FLAG_BINARY_CIPHERTEXT = 0x01;
56
+ const PARAM_FLAGS_KNOWN = PARAM_FLAG_BINARY_CIPHERTEXT;
57
+ const HANDSHAKE_RETRY_MS = 1000;
58
+ const MAX_PENDING_SENDS = 200;
59
+ // ---------------------------------------------------------------------------
60
+ // Client
61
+ // ---------------------------------------------------------------------------
62
+ /**
63
+ * Secures a transport as the initiator. Resolves once the hello has been sent;
64
+ * `events.onopen` fires when the channel is usable. Sends queued before open
65
+ * are flushed in order after the handshake, capped at {@link MAX_PENDING_SENDS}.
66
+ */
67
+ export async function openClientSecureChannel(transport, options) {
68
+ const events = options.events ?? {};
69
+ const minimumMode = options.minimumMode ?? "legacy";
70
+ const daemonPublicKey = importPublicKey(options.daemonPublicKeyB64);
71
+ // Separate ephemeral keys per protocol: the legacy box key is routing and
72
+ // compatibility input only; the Noise ephemeral lives inside the handshake.
73
+ const legacyKeyPair = generateKeyPair();
74
+ const legacySharedKey = deriveSharedKey(legacyKeyPair.secretKey, daemonPublicKey);
75
+ const noise = createNoiseInitiator({ remoteStatic: daemonPublicKey, prologue: NOISE_PROLOGUE });
76
+ const offered = { binaryCiphertext: true };
77
+ const msg1 = noise.writeMessage(encodeParams(offered));
78
+ const hello = {
79
+ type: "e2ee_hello",
80
+ key: exportPublicKey(legacyKeyPair.publicKey),
81
+ capabilities: { binaryCiphertext: true },
82
+ protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg1)) },
83
+ };
84
+ const helloText = JSON.stringify(hello);
85
+ const channel = new ClientSecureChannel(transport, events, {
86
+ minimumMode,
87
+ noise,
88
+ offered,
89
+ legacySharedKey,
90
+ });
91
+ let retry = null;
92
+ const emitSendError = (error) => {
93
+ events.onerror?.(error instanceof Error ? error : new Error(String(error)));
94
+ };
95
+ const sendHello = () => {
96
+ try {
97
+ const result = transport.send(helloText);
98
+ if (result)
99
+ void result.catch(emitSendError);
100
+ }
101
+ catch (error) {
102
+ // Daemon restarts move the socket through CLOSING/CLOSED; report, never throw from timers.
103
+ emitSendError(error);
104
+ }
105
+ };
106
+ const clearRetry = () => {
107
+ if (retry) {
108
+ clearInterval(retry);
109
+ retry = null;
110
+ }
111
+ };
112
+ channel.onTransitionToOpen(clearRetry);
113
+ channel.onClose(clearRetry);
114
+ sendHello();
115
+ retry = setInterval(() => {
116
+ if (channel.isOpen()) {
117
+ clearRetry();
118
+ return;
119
+ }
120
+ sendHello();
121
+ }, HANDSHAKE_RETRY_MS);
122
+ unref(retry);
123
+ return channel;
124
+ }
125
+ /**
126
+ * Client channel: waits for the daemon's ready, selects the mode, then
127
+ * delegates records to the mode's implementation.
128
+ */
129
+ class ClientSecureChannel {
130
+ constructor(transport, events, handshake) {
131
+ this.transport = transport;
132
+ this.events = events;
133
+ this.state = "handshaking";
134
+ this.closeNotified = false;
135
+ this.inner = null;
136
+ this.pendingSends = [];
137
+ this.onOpenCallbacks = [];
138
+ this.onCloseCallbacks = [];
139
+ this.handshake = handshake;
140
+ Object.assign(transport, {
141
+ onmessage: (message) => this.handleHandshakeMessage(message),
142
+ onclose: (code, reason) => this.handleTransportClose(code, reason),
143
+ onerror: (error) => this.events.onerror?.(error),
144
+ });
145
+ }
146
+ handleHandshakeMessage(message) {
147
+ if (this.state !== "handshaking" || !this.handshake)
148
+ return;
149
+ let parsed;
150
+ try {
151
+ if (message.isBinary)
152
+ return;
153
+ parsed = JSON.parse(decodeTransportText(message.data));
154
+ }
155
+ catch {
156
+ return; // not handshake traffic; ignore while handshaking
157
+ }
158
+ if (!isRecord(parsed) || parsed.type !== "e2ee_ready")
159
+ return;
160
+ try {
161
+ // A ready the daemon recognizably sent but that fails validation is a
162
+ // broken or tampered handshake, never unrelated traffic to wait past.
163
+ if (!isReadyEnvelope(parsed))
164
+ throw new NoiseProtocolError("malformed ready envelope");
165
+ const ready = parsed;
166
+ if (ready.protected !== undefined) {
167
+ this.openProtected(ready.protected);
168
+ }
169
+ else {
170
+ this.openLegacy(ready);
171
+ }
172
+ }
173
+ catch (error) {
174
+ const err = error instanceof Error ? error : new Error(String(error));
175
+ this.events.onerror?.(err);
176
+ this.fail(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, err.message);
177
+ }
178
+ }
179
+ openProtected(envelope) {
180
+ const handshake = this.handshake;
181
+ if (envelope.v !== PROTECTED_ENVELOPE_VERSION) {
182
+ throw new NoiseProtocolError("unsupported protected envelope version");
183
+ }
184
+ const msg2 = new Uint8Array(base64ToArrayBuffer(envelope.message));
185
+ const selected = decodeParams(handshake.noise.readMessage(msg2));
186
+ if (selected.binaryCiphertext && !handshake.offered.binaryCiphertext) {
187
+ throw new NoiseProtocolError("daemon selected an unoffered parameter");
188
+ }
189
+ const keys = handshake.noise.split();
190
+ this.becomeOpen(new ProtectedChannel(this.transport, this.events, keys.send, keys.receive, selected, {
191
+ retry: null,
192
+ failureCloseCode: PROTECTED_RECORD_FAILED_CLOSE_CODE,
193
+ }));
194
+ }
195
+ openLegacy(ready) {
196
+ const handshake = this.handshake;
197
+ if (handshake.minimumMode === "protected") {
198
+ throw new Error("daemon offered only legacy endpoint security");
199
+ }
200
+ const legacy = new EncryptedChannel(this.transport, handshake.legacySharedKey, this.events, {
201
+ binaryCiphertext: ready.capabilities?.binaryCiphertext === true,
202
+ });
203
+ legacy.setState("open");
204
+ this.becomeOpen(new LegacyInner(legacy));
205
+ }
206
+ becomeOpen(inner) {
207
+ this.handshake = null;
208
+ this.inner = inner;
209
+ this.state = "open";
210
+ // The mode implementation now owns the transport callbacks; mirror its close.
211
+ inner.onClose(() => this.notifyClosed());
212
+ this.events.onopen?.();
213
+ for (const cb of this.onOpenCallbacks)
214
+ cb();
215
+ const pending = this.pendingSends;
216
+ this.pendingSends = [];
217
+ void (async () => {
218
+ try {
219
+ for (const item of pending)
220
+ await inner.send(item);
221
+ }
222
+ catch (error) {
223
+ const err = error instanceof Error ? error : new Error(String(error));
224
+ this.events.onerror?.(err);
225
+ this.fail(PROTECTED_RECORD_FAILED_CLOSE_CODE, err.message);
226
+ }
227
+ })();
228
+ }
229
+ /** Transport closed while this wrapper still owned its callbacks (handshaking). */
230
+ handleTransportClose(code, reason) {
231
+ this.events.onclose?.(code, reason);
232
+ this.notifyClosed();
233
+ }
234
+ notifyClosed() {
235
+ this.state = "closed";
236
+ this.handshake = null;
237
+ if (this.closeNotified)
238
+ return;
239
+ this.closeNotified = true;
240
+ for (const cb of this.onCloseCallbacks)
241
+ cb();
242
+ }
243
+ fail(code, reason) {
244
+ if (this.state === "closed")
245
+ return;
246
+ this.state = "closed";
247
+ this.handshake = null;
248
+ try {
249
+ this.transport.close(code, reason);
250
+ }
251
+ catch {
252
+ // ignore
253
+ }
254
+ }
255
+ async send(data) {
256
+ if (this.state === "handshaking") {
257
+ if (this.pendingSends.length >= MAX_PENDING_SENDS)
258
+ this.pendingSends.shift();
259
+ this.pendingSends.push(data);
260
+ return;
261
+ }
262
+ if (this.state !== "open" || !this.inner)
263
+ throw new Error("Channel not open");
264
+ await this.inner.send(data);
265
+ }
266
+ close(code = 1000, reason = "Normal closure") {
267
+ if (this.inner) {
268
+ this.inner.close(code, reason);
269
+ }
270
+ else {
271
+ this.transport.close(code, reason);
272
+ }
273
+ this.state = "closed";
274
+ this.handshake = null;
275
+ }
276
+ isOpen() {
277
+ return this.state === "open" && this.inner?.isOpen() === true;
278
+ }
279
+ securityMode() {
280
+ if (!this.inner)
281
+ throw new Error("security mode is unknown until the channel opens");
282
+ return this.inner.mode;
283
+ }
284
+ outboundWireByteLength(data) {
285
+ if (this.inner)
286
+ return this.inner.outboundWireByteLength(data);
287
+ // Mode unknown while handshaking: report the larger of the two so callers stay within limits.
288
+ const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength;
289
+ return Math.max(base64EncryptedWireByteLength(plaintextBytes), 4 * Math.ceil(protectedRecordByteLength(plaintextBytes) / 3));
290
+ }
291
+ onTransitionToOpen(cb) {
292
+ this.onOpenCallbacks.push(cb);
293
+ }
294
+ onClose(cb) {
295
+ this.onCloseCallbacks.push(cb);
296
+ }
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Daemon
300
+ // ---------------------------------------------------------------------------
301
+ /**
302
+ * Secures a transport as the responder. Resolves once the peer's hello has
303
+ * been accepted and the ready sent. Rejects with
304
+ * {@link SecureChannelRejectedError} when policy refuses the peer, and with a
305
+ * plain error for malformed or failed handshakes; the caller closes the socket.
306
+ */
307
+ export async function openDaemonSecureChannel(transport, options) {
308
+ const events = options.events ?? {};
309
+ const minimumMode = () => typeof options.minimumMode === "function"
310
+ ? options.minimumMode()
311
+ : (options.minimumMode ?? "legacy");
312
+ return new Promise((resolve, reject) => {
313
+ const buffered = [];
314
+ const handleHello = async (message) => {
315
+ try {
316
+ if (message.isBinary)
317
+ throw new Error("Invalid hello message (binary frame)");
318
+ const helloText = decodeTransportText(message.data);
319
+ let parsed;
320
+ try {
321
+ parsed = JSON.parse(helloText);
322
+ }
323
+ catch {
324
+ throw new Error(`Invalid hello message: ${preview(helloText)}`);
325
+ }
326
+ if (!isHelloEnvelope(parsed))
327
+ throw new Error(`Invalid hello message: ${preview(helloText)}`);
328
+ // Buffer traffic that arrives while the ready is in flight so an early
329
+ // record is not mistaken for a second hello.
330
+ Object.assign(transport, { onmessage: (next) => buffered.push(next) });
331
+ const channel = parsed.protected !== undefined
332
+ ? await acceptProtectedHello(transport, events, options.daemonKeyPair, parsed, helloText)
333
+ : await acceptLegacyHello(transport, events, options.daemonKeyPair, parsed, minimumMode());
334
+ events.onopen?.();
335
+ // The channel now owns the transport: buffered records are decoded and a
336
+ // buffered hello retry gets the same exact-transcript answer as a live one.
337
+ for (const next of buffered)
338
+ transport.onmessage?.(next);
339
+ resolve(channel);
340
+ }
341
+ catch (error) {
342
+ reject(error);
343
+ }
344
+ };
345
+ Object.assign(transport, {
346
+ onmessage: handleHello,
347
+ onerror: (error) => reject(error),
348
+ onclose: (code, reason) => reject(new Error(`Connection closed during handshake: ${code} ${reason}`)),
349
+ });
350
+ });
351
+ }
352
+ async function acceptProtectedHello(transport, events, daemonKeyPair, hello, helloText) {
353
+ const envelope = hello.protected;
354
+ if (envelope.v !== PROTECTED_ENVELOPE_VERSION) {
355
+ throw new NoiseProtocolError("unsupported protected envelope version");
356
+ }
357
+ const noise = createNoiseResponder({ localStatic: daemonKeyPair, prologue: NOISE_PROLOGUE });
358
+ const msg1 = new Uint8Array(base64ToArrayBuffer(envelope.message));
359
+ const offered = decodeParams(noise.readMessage(msg1));
360
+ const selected = { binaryCiphertext: offered.binaryCiphertext };
361
+ const msg2 = noise.writeMessage(encodeParams(selected));
362
+ const ready = {
363
+ type: "e2ee_ready",
364
+ protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg2)) },
365
+ };
366
+ const readyText = JSON.stringify(ready);
367
+ await transport.send(readyText);
368
+ const keys = noise.split();
369
+ return new ProtectedChannel(transport, events, keys.send, keys.receive, selected, {
370
+ retry: { helloText, readyText },
371
+ failureCloseCode: DAEMON_RECORD_FAILURE_CLOSE_CODE,
372
+ });
373
+ }
374
+ async function acceptLegacyHello(transport, events, daemonKeyPair, hello, minimumMode) {
375
+ if (minimumMode === "protected") {
376
+ throw new SecureChannelRejectedError("client offered only legacy endpoint security", PROTECTED_MODE_REQUIRED_CLOSE_CODE, PROTECTED_MODE_REQUIRED_CLOSE_REASON);
377
+ }
378
+ const clientPublicKey = importPublicKey(hello.key);
379
+ const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
380
+ const binaryCiphertext = hello.capabilities?.binaryCiphertext === true;
381
+ await transport.send(JSON.stringify({
382
+ type: "e2ee_ready",
383
+ ...(binaryCiphertext ? { capabilities: { binaryCiphertext: true } } : {}),
384
+ }));
385
+ const legacy = new EncryptedChannel(transport, sharedKey, events, {
386
+ daemonKeyPair,
387
+ binaryCiphertext,
388
+ });
389
+ legacy.setState("open");
390
+ return new LegacyInner(legacy);
391
+ }
392
+ class LegacyInner {
393
+ constructor(legacy) {
394
+ this.legacy = legacy;
395
+ this.mode = "legacy";
396
+ }
397
+ send(data) {
398
+ return this.legacy.send(data);
399
+ }
400
+ close(code, reason) {
401
+ this.legacy.close(code, reason);
402
+ }
403
+ isOpen() {
404
+ return this.legacy.isOpen();
405
+ }
406
+ securityMode() {
407
+ return this.mode;
408
+ }
409
+ outboundWireByteLength(data) {
410
+ return this.legacy.outboundWireByteLength(data);
411
+ }
412
+ onTransitionToOpen(cb) {
413
+ this.legacy.onTransitionToOpen(cb);
414
+ }
415
+ onClose(cb) {
416
+ this.legacy.onClose(cb);
417
+ }
418
+ }
419
+ /**
420
+ * Protected records over an open transport. Every inbound record is
421
+ * authenticated and framing-checked before delivery; any failure closes the
422
+ * channel and clears the keys.
423
+ */
424
+ class ProtectedChannel {
425
+ constructor(transport, events, sender, receiver, params, role) {
426
+ this.transport = transport;
427
+ this.events = events;
428
+ this.sender = sender;
429
+ this.receiver = receiver;
430
+ this.params = params;
431
+ this.role = role;
432
+ this.mode = "protected";
433
+ this.state = "open";
434
+ this.onCloseCallbacks = [];
435
+ Object.assign(transport, {
436
+ onmessage: (message) => this.handleMessage(message),
437
+ onclose: (code, reason) => {
438
+ this.markClosed();
439
+ this.events.onclose?.(code, reason);
440
+ },
441
+ onerror: (error) => this.events.onerror?.(error),
442
+ });
443
+ }
444
+ async handleMessage(message) {
445
+ if (this.state !== "open")
446
+ return;
447
+ try {
448
+ const handshake = !message.isBinary ? parseHandshakeEnvelope(message.data) : null;
449
+ if (handshake) {
450
+ await this.handleHandshakeTraffic(handshake);
451
+ return;
452
+ }
453
+ const record = message.isBinary
454
+ ? new Uint8Array(requireArrayBuffer(message.data))
455
+ : new Uint8Array(base64ToArrayBuffer(decodeTransportText(message.data)));
456
+ const frame = decodeProtectedRecord(record, this.receiver);
457
+ const plaintext = frame.kind === "text"
458
+ ? new TextDecoder("utf-8", { fatal: true }).decode(frame.data)
459
+ : toArrayBuffer(frame.data);
460
+ this.events.onmessage?.(plaintext);
461
+ }
462
+ catch (error) {
463
+ const err = error instanceof Error ? error : new Error(String(error));
464
+ this.fail(this.role.failureCloseCode, err.message);
465
+ }
466
+ }
467
+ async handleHandshakeTraffic(handshake) {
468
+ if (handshake.kind === "ready")
469
+ return; // client ignores duplicate ready after open
470
+ if (!this.role.retry)
471
+ return; // a client never answers hellos
472
+ if (handshake.text === this.role.retry.helloText) {
473
+ await this.transport.send(this.role.retry.readyText);
474
+ return;
475
+ }
476
+ this.fail(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_MISMATCH_CLOSE_REASON);
477
+ }
478
+ async send(data) {
479
+ if (this.state !== "open")
480
+ throw new Error("Channel not open");
481
+ const kind = typeof data === "string" ? "text" : "binary";
482
+ const frame = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
483
+ let wire;
484
+ try {
485
+ // Synchronous up to here: the nonce is reserved before any await.
486
+ wire = encodeProtectedRecord(kind, frame, this.sender);
487
+ }
488
+ catch (error) {
489
+ const err = error instanceof Error ? error : new Error(String(error));
490
+ this.fail(this.role.failureCloseCode, err.message);
491
+ throw err;
492
+ }
493
+ if (this.params.binaryCiphertext && kind === "binary") {
494
+ await this.transport.send(toArrayBuffer(wire));
495
+ return;
496
+ }
497
+ await this.transport.send(arrayBufferToBase64(toArrayBuffer(wire)));
498
+ }
499
+ outboundWireByteLength(data) {
500
+ const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength;
501
+ const wireBytes = protectedRecordByteLength(plaintextBytes);
502
+ if (this.params.binaryCiphertext && typeof data !== "string")
503
+ return wireBytes;
504
+ return 4 * Math.ceil(wireBytes / 3);
505
+ }
506
+ close(code = 1000, reason = "Normal closure") {
507
+ this.markClosed();
508
+ this.transport.close(code, reason);
509
+ }
510
+ fail(code, reason) {
511
+ if (this.state !== "open")
512
+ return;
513
+ this.markClosed();
514
+ try {
515
+ this.transport.close(code, reason);
516
+ }
517
+ catch {
518
+ // ignore
519
+ }
520
+ }
521
+ markClosed() {
522
+ if (this.state === "closed")
523
+ return;
524
+ this.state = "closed";
525
+ this.sender.clear();
526
+ this.receiver.clear();
527
+ for (const cb of this.onCloseCallbacks)
528
+ cb();
529
+ }
530
+ isOpen() {
531
+ return this.state === "open";
532
+ }
533
+ securityMode() {
534
+ return this.mode;
535
+ }
536
+ onTransitionToOpen(cb) {
537
+ cb();
538
+ }
539
+ onClose(cb) {
540
+ this.onCloseCallbacks.push(cb);
541
+ }
542
+ }
543
+ /**
544
+ * Largest plaintext that fits in `wireBytes` as a base64 text frame in either
545
+ * mode. Callers size payloads before the mode is known, so take the tighter bound.
546
+ */
547
+ export function maxSecuredPlaintextByteLength(wireBytes) {
548
+ const legacy = maxBase64EncryptedPlaintextByteLength(wireBytes);
549
+ const base64Capacity = Math.floor(wireBytes / 4) * 3;
550
+ let protectedBound = base64Capacity;
551
+ while (protectedBound > 0 && protectedRecordByteLength(protectedBound) > base64Capacity) {
552
+ protectedBound = base64Capacity - (protectedRecordByteLength(protectedBound) - protectedBound);
553
+ }
554
+ return Math.max(0, Math.min(legacy, protectedBound));
555
+ }
556
+ // ---------------------------------------------------------------------------
557
+ // Envelope parsing and parameter encoding
558
+ // ---------------------------------------------------------------------------
559
+ function isRecord(value) {
560
+ return typeof value === "object" && value !== null && !Array.isArray(value);
561
+ }
562
+ function isCapabilities(value) {
563
+ return (value === undefined ||
564
+ (isRecord(value) &&
565
+ (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")));
566
+ }
567
+ function isProtectedEnvelope(value) {
568
+ return (isRecord(value) &&
569
+ typeof value.v === "number" &&
570
+ typeof value.message === "string" &&
571
+ value.message.length > 0);
572
+ }
573
+ function isHelloEnvelope(value) {
574
+ return (isRecord(value) &&
575
+ value.type === "e2ee_hello" &&
576
+ typeof value.key === "string" &&
577
+ value.key.trim().length > 0 &&
578
+ isCapabilities(value.capabilities) &&
579
+ (value.protected === undefined || isProtectedEnvelope(value.protected)));
580
+ }
581
+ function isReadyEnvelope(value) {
582
+ return (isRecord(value) &&
583
+ value.type === "e2ee_ready" &&
584
+ isCapabilities(value.capabilities) &&
585
+ (value.protected === undefined || isProtectedEnvelope(value.protected)));
586
+ }
587
+ /** Classifies plaintext handshake traffic seen on an open channel; null for anything else. */
588
+ function parseHandshakeEnvelope(data) {
589
+ let text;
590
+ try {
591
+ text = decodeTransportText(data);
592
+ }
593
+ catch {
594
+ return null;
595
+ }
596
+ if (!text.trim().startsWith("{"))
597
+ return null;
598
+ let parsed;
599
+ try {
600
+ parsed = JSON.parse(text);
601
+ }
602
+ catch {
603
+ throw new Error("Received plaintext frame on encrypted channel");
604
+ }
605
+ if (isRecord(parsed) && parsed.type === "e2ee_hello")
606
+ return { kind: "hello", text };
607
+ if (isRecord(parsed) && parsed.type === "e2ee_ready")
608
+ return { kind: "ready" };
609
+ // Any other JSON is plaintext app traffic: the peer is not encrypting.
610
+ throw new Error("Received plaintext frame on encrypted channel");
611
+ }
612
+ function encodeParams(params) {
613
+ return new Uint8Array([
614
+ PARAMS_VERSION,
615
+ params.binaryCiphertext ? PARAM_FLAG_BINARY_CIPHERTEXT : 0,
616
+ ]);
617
+ }
618
+ function decodeParams(payload) {
619
+ if (payload.byteLength !== 2)
620
+ throw new NoiseProtocolError("invalid parameter payload");
621
+ if (payload[0] !== PARAMS_VERSION)
622
+ throw new NoiseProtocolError("unsupported parameter version");
623
+ if ((payload[1] & ~PARAM_FLAGS_KNOWN) !== 0)
624
+ throw new NoiseProtocolError("reserved parameter flag set");
625
+ return { binaryCiphertext: (payload[1] & PARAM_FLAG_BINARY_CIPHERTEXT) !== 0 };
626
+ }
627
+ function preview(text) {
628
+ const compact = text.replace(/\s+/g, " ").trim();
629
+ return JSON.stringify(compact.length > 160 ? `${compact.slice(0, 157)}...` : compact);
630
+ }
631
+ function decodeTransportText(data) {
632
+ return typeof data === "string" ? data : new TextDecoder().decode(data);
633
+ }
634
+ function requireArrayBuffer(data) {
635
+ if (data instanceof ArrayBuffer)
636
+ return data;
637
+ throw new Error("Binary WebSocket frame did not contain bytes");
638
+ }
639
+ function toArrayBuffer(bytes) {
640
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
641
+ }
642
+ function utf8ByteLength(text) {
643
+ return new TextEncoder().encode(text).byteLength;
644
+ }
645
+ function unref(timer) {
646
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
647
+ timer.unref();
648
+ }
649
+ }
650
+ //# sourceMappingURL=secure-channel.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camerontaylor/paseo-relay",
3
- "version": "0.12.0-fork.1",
3
+ "version": "0.13.0-fork.2",
4
4
  "description": "Paseo relay for bridging daemon and client connections",
5
5
  "files": [
6
6
  "dist",
@@ -35,6 +35,9 @@
35
35
  "test:watch": "vitest"
36
36
  },
37
37
  "dependencies": {
38
+ "@noble/ciphers": "^2.4.0",
39
+ "@noble/curves": "^2.4.0",
40
+ "@noble/hashes": "^2.4.0",
38
41
  "base64-js": "^1.5.1",
39
42
  "tweetnacl": "^1.0.3",
40
43
  "ws": "^8.14.2"