@camerontaylor/paseo-relay 0.8.0-fork.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.
@@ -0,0 +1,452 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Encrypted channel that wraps a WebSocket-like transport.
4
+ *
5
+ * Handles ECDH handshake and encrypts/decrypts all messages.
6
+ * Works identically for daemon and client sides.
7
+ */
8
+ import { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
9
+ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
10
+ function isRecord(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ function isE2EECapabilities(value) {
14
+ return (value === undefined ||
15
+ (isRecord(value) &&
16
+ (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")));
17
+ }
18
+ function isE2EEHelloMessage(value) {
19
+ return (isRecord(value) &&
20
+ value.type === "e2ee_hello" &&
21
+ typeof value.key === "string" &&
22
+ value.key.trim().length > 0 &&
23
+ isE2EECapabilities(value.capabilities));
24
+ }
25
+ function isE2EEReadyMessage(value) {
26
+ return isRecord(value) && value.type === "e2ee_ready" && isE2EECapabilities(value.capabilities);
27
+ }
28
+ function supportsBinaryCiphertext(message) {
29
+ return message.capabilities?.binaryCiphertext === true;
30
+ }
31
+ function buildInvalidHelloError(rawText, parsed) {
32
+ const parsedRecord = isRecord(parsed) ? parsed : null;
33
+ const rawType = parsedRecord?.type;
34
+ function describeType(value) {
35
+ if (typeof value === "string")
36
+ return value;
37
+ if (value === undefined)
38
+ return "undefined";
39
+ return typeof value;
40
+ }
41
+ const receivedType = describeType(rawType);
42
+ const hasKey = typeof parsedRecord?.key === "string" && parsedRecord.key.trim().length > 0;
43
+ const compact = rawText.replace(/\s+/g, " ").trim();
44
+ const preview = compact.length > 160 ? `${compact.slice(0, 157)}...` : compact;
45
+ return new Error(`Invalid hello message (receivedType=${receivedType}, hasKey=${hasKey}, preview=${JSON.stringify(preview)})`);
46
+ }
47
+ const HANDSHAKE_RETRY_MS = 1000;
48
+ const MAX_PENDING_SENDS = 200;
49
+ const REHANDSHAKE_REJECTION_CODE = 1008;
50
+ const ENCRYPTED_PAYLOAD_OVERHEAD_BYTES = 40;
51
+ export function base64EncryptedWireByteLength(plaintextBytes) {
52
+ return 4 * Math.ceil((plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES) / 3);
53
+ }
54
+ export function maxBase64EncryptedPlaintextByteLength(wireBytes) {
55
+ return Math.floor(wireBytes / 4) * 3 - ENCRYPTED_PAYLOAD_OVERHEAD_BYTES;
56
+ }
57
+ const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
58
+ function hasUnref(timeout) {
59
+ return (typeof timeout === "object" &&
60
+ timeout !== null &&
61
+ "unref" in timeout &&
62
+ typeof timeout.unref === "function");
63
+ }
64
+ /**
65
+ * Creates an encrypted channel as the initiator (client).
66
+ *
67
+ * The client:
68
+ * 1. Receives daemon's public key via QR code
69
+ * 2. Generates own keypair
70
+ * 3. Sends e2ee_hello with own public key
71
+ * 4. Derives shared key and starts encrypted communication
72
+ */
73
+ export async function createClientChannel(transport, daemonPublicKeyB64, events = {}) {
74
+ const keyPair = generateKeyPair();
75
+ const daemonPublicKey = importPublicKey(daemonPublicKeyB64);
76
+ const sharedKey = deriveSharedKey(keyPair.secretKey, daemonPublicKey);
77
+ const channel = new EncryptedChannel(transport, sharedKey, events);
78
+ // Send e2ee_hello with our public key
79
+ const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
80
+ const hello = {
81
+ type: "e2ee_hello",
82
+ key: ourPublicKeyB64,
83
+ capabilities: { binaryCiphertext: true },
84
+ };
85
+ const helloText = JSON.stringify(hello);
86
+ let retry = null;
87
+ const emitSendError = (error) => {
88
+ const err = error instanceof Error ? error : new Error(String(error));
89
+ events.onerror?.(err);
90
+ };
91
+ const sendHello = () => {
92
+ try {
93
+ const result = transport.send(helloText);
94
+ if (result) {
95
+ void result.catch(emitSendError);
96
+ }
97
+ return true;
98
+ }
99
+ catch (error) {
100
+ // This can happen during daemon restarts while the socket transitions
101
+ // through CLOSING/CLOSED states. Report it but do not throw from timers.
102
+ emitSendError(error);
103
+ return false;
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
+ // Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
123
+ if (hasUnref(retry)) {
124
+ retry.unref();
125
+ }
126
+ return channel;
127
+ }
128
+ /**
129
+ * Creates an encrypted channel as the responder (daemon).
130
+ *
131
+ * The daemon:
132
+ * 1. Has pre-generated keypair (public key was in QR)
133
+ * 2. Waits for client's e2ee_hello with their public key
134
+ * 3. Derives shared key and starts encrypted communication
135
+ */
136
+ export async function createDaemonChannel(transport, daemonKeyPair, events = {}) {
137
+ return new Promise((resolve, reject) => {
138
+ const bufferedMessages = [];
139
+ const shouldIgnorePostHelloPlaintext = (message) => {
140
+ try {
141
+ if (message.isBinary)
142
+ return false;
143
+ const text = decodeTransportText(message.data);
144
+ const parsed = JSON.parse(text);
145
+ return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ };
151
+ const handleHello = async (message) => {
152
+ try {
153
+ if (message.isBinary) {
154
+ throw buildInvalidHelloError("<binary frame>");
155
+ }
156
+ const helloText = decodeTransportText(message.data);
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(helloText);
160
+ }
161
+ catch {
162
+ throw buildInvalidHelloError(helloText);
163
+ }
164
+ if (!isE2EEHelloMessage(parsed)) {
165
+ throw buildInvalidHelloError(helloText, parsed);
166
+ }
167
+ const msg = parsed;
168
+ // Buffer any subsequent messages that arrive while we're doing async
169
+ // WebCrypto work to derive the shared key. Without this, it's possible
170
+ // for the next message (already encrypted) to be misinterpreted as a
171
+ // second hello, causing the handshake to fail.
172
+ const bufferNext = (next) => {
173
+ bufferedMessages.push(next);
174
+ };
175
+ Object.assign(transport, { onmessage: bufferNext });
176
+ const clientPublicKey = importPublicKey(msg.key);
177
+ const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
178
+ const binaryCiphertext = supportsBinaryCiphertext(msg);
179
+ await transport.send(JSON.stringify({
180
+ type: "e2ee_ready",
181
+ ...(binaryCiphertext
182
+ ? { capabilities: { binaryCiphertext: true } }
183
+ : {}),
184
+ }));
185
+ const channel = new EncryptedChannel(transport, sharedKey, events, {
186
+ daemonKeyPair,
187
+ binaryCiphertext,
188
+ });
189
+ channel.setState("open");
190
+ events.onopen?.();
191
+ for (const buffered of bufferedMessages) {
192
+ if (shouldIgnorePostHelloPlaintext(buffered))
193
+ continue;
194
+ transport.onmessage?.(buffered);
195
+ }
196
+ resolve(channel);
197
+ }
198
+ catch (error) {
199
+ reject(error);
200
+ }
201
+ };
202
+ Object.assign(transport, {
203
+ onmessage: handleHello,
204
+ onerror: (error) => {
205
+ reject(error);
206
+ },
207
+ onclose: (code, reason) => {
208
+ reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
209
+ },
210
+ });
211
+ });
212
+ }
213
+ /**
214
+ * Encrypted channel that wraps a transport with E2EE.
215
+ */
216
+ export class EncryptedChannel {
217
+ constructor(transport, sharedKey, events = {}, options = {}) {
218
+ this.state = "handshaking";
219
+ this.pendingSends = [];
220
+ this.onOpenCallbacks = [];
221
+ this.onCloseCallbacks = [];
222
+ this.transport = transport;
223
+ this.sharedKey = sharedKey;
224
+ this.events = events;
225
+ this.options = options;
226
+ Object.assign(transport, {
227
+ onmessage: (message) => this.handleMessage(message),
228
+ onclose: (code, reason) => {
229
+ this.state = "closed";
230
+ this.events.onclose?.(code, reason);
231
+ for (const cb of this.onCloseCallbacks)
232
+ cb();
233
+ },
234
+ onerror: (error) => {
235
+ this.events.onerror?.(error);
236
+ },
237
+ });
238
+ }
239
+ setState(state) {
240
+ this.state = state;
241
+ }
242
+ async handleMessage(message) {
243
+ if (this.state === "handshaking") {
244
+ try {
245
+ if (message.isBinary)
246
+ return;
247
+ const text = decodeTransportText(message.data);
248
+ const parsed = JSON.parse(text);
249
+ if (isE2EEReadyMessage(parsed)) {
250
+ this.options.binaryCiphertext = supportsBinaryCiphertext(parsed);
251
+ this.state = "open";
252
+ this.events.onopen?.();
253
+ for (const cb of this.onOpenCallbacks)
254
+ cb();
255
+ try {
256
+ await this.flushPendingSends();
257
+ }
258
+ catch (error) {
259
+ const err = error instanceof Error ? error : new Error(String(error));
260
+ this.events.onerror?.(err);
261
+ this.state = "closed";
262
+ this.transport.close(1011, err.message);
263
+ }
264
+ }
265
+ }
266
+ catch {
267
+ // ignore non-ready handshake traffic
268
+ }
269
+ return;
270
+ }
271
+ if (this.state !== "open")
272
+ return;
273
+ try {
274
+ const ciphertext = await (async () => {
275
+ // Handle (or ignore) any stray plaintext handshake traffic.
276
+ try {
277
+ if (message.isBinary)
278
+ throw new Error("not plaintext handshake traffic");
279
+ const text = decodeTransportText(message.data);
280
+ if (text.trim().startsWith("{")) {
281
+ const parsed = JSON.parse(text);
282
+ if (isE2EEHelloMessage(parsed)) {
283
+ if (this.options.daemonKeyPair) {
284
+ await this.handleDaemonRehello(parsed);
285
+ }
286
+ return null;
287
+ }
288
+ if (isE2EEReadyMessage(parsed)) {
289
+ return null;
290
+ }
291
+ // Any other JSON-looking payload is plaintext app traffic, which
292
+ // means the peer is not encrypting (or we are out of sync).
293
+ throw new Error("Received plaintext frame on encrypted channel");
294
+ }
295
+ }
296
+ catch (error) {
297
+ // If we detected plaintext protocol mismatch, fail hard.
298
+ if (error instanceof Error && error.message.includes("plaintext frame")) {
299
+ throw error;
300
+ }
301
+ // Otherwise ignore JSON parse/TextDecoder failures and fall back to
302
+ // decoding ciphertext below.
303
+ }
304
+ if (this.options.binaryCiphertext) {
305
+ return message.isBinary
306
+ ? { data: requireArrayBuffer(message.data), isBinary: true }
307
+ : {
308
+ data: base64ToArrayBuffer(decodeTransportText(message.data)),
309
+ isBinary: false,
310
+ };
311
+ }
312
+ // COMPAT(binaryCiphertext): added in v0.2.3, remove legacy base64-only
313
+ // receive mode after 2027-01-27.
314
+ if (!message.isBinary) {
315
+ return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
316
+ }
317
+ // Older transport adapters could lose the opcode. Retain the former
318
+ // base64-first behavior only in the legacy path.
319
+ try {
320
+ return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
321
+ }
322
+ catch {
323
+ return { data: requireArrayBuffer(message.data), isBinary: null };
324
+ }
325
+ })();
326
+ if (ciphertext) {
327
+ const plaintextBytes = decrypt(this.sharedKey, ciphertext.data);
328
+ const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary);
329
+ this.events.onmessage?.(plaintext);
330
+ }
331
+ }
332
+ catch (error) {
333
+ const err = error instanceof Error ? error : new Error(String(error));
334
+ // Treat decryption/protocol errors as fatal so the peer can reconnect and
335
+ // re-handshake. Emitting an error event here can cause higher-level code
336
+ // to tear down the session without triggering a clean reconnect.
337
+ try {
338
+ this.transport.close(1011, err.message);
339
+ }
340
+ catch {
341
+ // ignore
342
+ }
343
+ }
344
+ }
345
+ async send(data) {
346
+ if (this.state === "handshaking") {
347
+ if (this.pendingSends.length >= MAX_PENDING_SENDS) {
348
+ this.pendingSends.shift();
349
+ }
350
+ this.pendingSends.push(data);
351
+ return;
352
+ }
353
+ if (this.state !== "open") {
354
+ throw new Error("Channel not open");
355
+ }
356
+ const ciphertext = encrypt(this.sharedKey, data);
357
+ if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
358
+ await this.transport.send(ciphertext);
359
+ return;
360
+ }
361
+ // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends
362
+ // after 2027-01-27 once the supported peer floor includes negotiation.
363
+ await this.transport.send(arrayBufferToBase64(ciphertext));
364
+ }
365
+ outboundWireByteLength(data) {
366
+ const plaintextBytes = utf8ByteLength(data);
367
+ const encryptedBytes = plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES;
368
+ if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
369
+ return encryptedBytes;
370
+ }
371
+ return base64EncryptedWireByteLength(plaintextBytes);
372
+ }
373
+ async flushPendingSends() {
374
+ if (this.state !== "open")
375
+ return;
376
+ const pending = this.pendingSends;
377
+ this.pendingSends = [];
378
+ for (const item of pending) {
379
+ await this.send(item);
380
+ }
381
+ }
382
+ async handleDaemonRehello(message) {
383
+ if (!this.options.daemonKeyPair)
384
+ return;
385
+ const clientPublicKey = importPublicKey(message.key);
386
+ const retryKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
387
+ if (!keysEqual(retryKey, this.sharedKey))
388
+ return this.rejectKeyRotation();
389
+ await this.sendReadyForRetry();
390
+ }
391
+ async sendReadyForRetry() {
392
+ await this.transport.send(JSON.stringify({
393
+ type: "e2ee_ready",
394
+ ...(this.options.binaryCiphertext
395
+ ? { capabilities: { binaryCiphertext: true } }
396
+ : {}),
397
+ }));
398
+ }
399
+ rejectKeyRotation() {
400
+ this.state = "closed";
401
+ this.transport.close(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON);
402
+ }
403
+ close(code = 1000, reason = "Normal closure") {
404
+ this.state = "closed";
405
+ this.transport.close(code, reason);
406
+ }
407
+ isOpen() {
408
+ return this.state === "open";
409
+ }
410
+ onTransitionToOpen(cb) {
411
+ this.onOpenCallbacks.push(cb);
412
+ }
413
+ onClose(cb) {
414
+ this.onCloseCallbacks.push(cb);
415
+ }
416
+ }
417
+ function decodeTransportText(data) {
418
+ return typeof data === "string" ? data : new TextDecoder().decode(data);
419
+ }
420
+ function requireArrayBuffer(data) {
421
+ if (data instanceof ArrayBuffer)
422
+ return data;
423
+ throw new Error("Binary WebSocket frame did not contain bytes");
424
+ }
425
+ function decodeLegacyPlaintext(data) {
426
+ try {
427
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
428
+ }
429
+ catch {
430
+ return data;
431
+ }
432
+ }
433
+ function decodePlaintext(data, isBinary) {
434
+ if (isBinary === true)
435
+ return data;
436
+ if (isBinary === false)
437
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
438
+ return decodeLegacyPlaintext(data);
439
+ }
440
+ function utf8ByteLength(data) {
441
+ return typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength;
442
+ }
443
+ function keysEqual(a, b) {
444
+ if (a.byteLength !== b.byteLength)
445
+ return false;
446
+ let difference = 0;
447
+ for (let i = 0; i < a.byteLength; i += 1) {
448
+ difference |= a[i] ^ b[i];
449
+ }
450
+ return difference === 0;
451
+ }
452
+ //# sourceMappingURL=encrypted-channel.js.map
@@ -0,0 +1,5 @@
1
+ export type { ConnectionRole, RelaySessionAttachment } from "./types.js";
2
+ export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
3
+ export { base64EncryptedWireByteLength, createClientChannel, createDaemonChannel, EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
4
+ export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
5
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
2
+ export { base64EncryptedWireByteLength, createClientChannel, createDaemonChannel, EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Relay connection types and interfaces.
3
+ *
4
+ * The relay bridges two WebSocket connections:
5
+ * - Server (daemon): The Paseo server connecting to the relay
6
+ * - Client (app): The mobile/web app connecting to the relay
7
+ *
8
+ * Messages are forwarded bidirectionally without modification.
9
+ */
10
+ export type ConnectionRole = "server" | "client";
11
+ export interface RelaySessionAttachment {
12
+ serverId: string;
13
+ role: ConnectionRole;
14
+ /**
15
+ * Relay protocol version carried by this socket.
16
+ * v1: single server/client socket pair
17
+ * v2: control + per-client data sockets
18
+ */
19
+ version?: "1" | "2";
20
+ /**
21
+ * Unique id for the connection. Allows the daemon to create an
22
+ * independent socket + E2EE channel per connected connection.
23
+ */
24
+ connectionId?: string | null;
25
+ createdAt: number;
26
+ }
27
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Relay connection types and interfaces.
3
+ *
4
+ * The relay bridges two WebSocket connections:
5
+ * - Server (daemon): The Paseo server connecting to the relay
6
+ * - Client (app): The mobile/web app connecting to the relay
7
+ *
8
+ * Messages are forwarded bidirectionally without modification.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@camerontaylor/paseo-relay",
3
+ "version": "0.8.0-fork.1",
4
+ "description": "Paseo relay for bridging daemon and client connections",
5
+ "files": [
6
+ "dist",
7
+ "!dist/**/*.map"
8
+ ],
9
+ "type": "module",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "node": "./dist/index.js",
15
+ "import": "./src/index.ts",
16
+ "default": "./src/index.ts"
17
+ },
18
+ "./e2ee": {
19
+ "types": "./dist/e2ee.d.ts",
20
+ "node": "./dist/e2ee.js",
21
+ "import": "./src/e2ee.ts",
22
+ "default": "./src/e2ee.ts"
23
+ },
24
+ "./cloudflare": {
25
+ "types": "./dist/cloudflare-adapter.d.ts",
26
+ "node": "./dist/cloudflare-adapter.js",
27
+ "import": "./src/cloudflare-adapter.ts",
28
+ "default": "./src/cloudflare-adapter.ts"
29
+ }
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "clean": "node ../../scripts/clean-package-dist.mjs",
36
+ "build": "tsc -p tsconfig.json --incremental false",
37
+ "build:clean": "npm run clean && npm run build",
38
+ "prepack": "npm run build:clean",
39
+ "typecheck": "tsgo --noEmit",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ },
43
+ "dependencies": {
44
+ "base64-js": "^1.5.1",
45
+ "tweetnacl": "^1.0.3",
46
+ "ws": "^8.14.2"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^20.9.0",
50
+ "@types/ws": "^8.5.8",
51
+ "typescript": "^5.2.2",
52
+ "vitest": "^4.1.6"
53
+ }
54
+ }