@getpaseo/relay 0.2.2 → 0.2.4

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/crypto.d.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  * Bundle format (binary):
8
8
  * [nonce (24 bytes)] [ciphertext...]
9
9
  *
10
- * Transport format:
11
- * The encrypted-channel sends the bundle as base64 text over WebSocket.
10
+ * The encrypted channel chooses the WebSocket representation. Crypto remains
11
+ * byte-oriented so frame kind is never inferred from plaintext contents.
12
12
  */
13
13
  export interface KeyPair {
14
14
  publicKey: Uint8Array;
@@ -26,5 +26,5 @@ export declare function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey:
26
26
  * [nonce (24)] [ciphertext...]
27
27
  */
28
28
  export declare function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): ArrayBuffer;
29
- export declare function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | ArrayBuffer;
29
+ export declare function decrypt(sharedKey: SharedKey, data: ArrayBuffer): ArrayBuffer;
30
30
  //# sourceMappingURL=crypto.d.ts.map
package/dist/crypto.js CHANGED
@@ -8,8 +8,8 @@
8
8
  * Bundle format (binary):
9
9
  * [nonce (24 bytes)] [ciphertext...]
10
10
  *
11
- * Transport format:
12
- * The encrypted-channel sends the bundle as base64 text over WebSocket.
11
+ * The encrypted channel chooses the WebSocket representation. Crypto remains
12
+ * byte-oriented so frame kind is never inferred from plaintext contents.
13
13
  */
14
14
  import nacl from "tweetnacl";
15
15
  import { fromByteArray, toByteArray } from "base64-js";
@@ -121,12 +121,6 @@ export function decrypt(sharedKey, data) {
121
121
  if (!opened) {
122
122
  throw new Error("Decryption failed");
123
123
  }
124
- const plaintext = toArrayBuffer(opened);
125
- try {
126
- return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
127
- }
128
- catch {
129
- return plaintext;
130
- }
124
+ return toArrayBuffer(opened);
131
125
  }
132
126
  //# sourceMappingURL=crypto.js.map
package/dist/e2ee.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
2
- export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
2
+ export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js";
3
3
  export { generateKeyPair, exportPublicKey, importPublicKey, exportSecretKey, importSecretKey, } from "./crypto.js";
4
4
  export type { KeyPair, SharedKey } from "./crypto.js";
5
5
  //# sourceMappingURL=e2ee.d.ts.map
@@ -6,12 +6,16 @@
6
6
  */
7
7
  import { type KeyPair, type SharedKey } from "./crypto.js";
8
8
  export interface Transport {
9
- send(data: string | ArrayBuffer): void;
9
+ send(data: string | ArrayBuffer): void | Promise<void>;
10
10
  close(code?: number, reason?: string): void;
11
- onmessage: ((data: string | ArrayBuffer) => void) | null;
11
+ onmessage: ((message: TransportMessage) => void) | null;
12
12
  onclose: ((code: number, reason: string) => void) | null;
13
13
  onerror: ((error: Error) => void) | null;
14
14
  }
15
+ export interface TransportMessage {
16
+ data: string | ArrayBuffer;
17
+ isBinary: boolean;
18
+ }
15
19
  export interface EncryptedChannelEvents {
16
20
  onopen?: () => void;
17
21
  onmessage?: (data: string | ArrayBuffer) => void;
@@ -29,7 +33,10 @@ interface EncryptedChannelOptions {
29
33
  * the daemon should re-send `{type:"e2ee_ready"}` without changing keys.
30
34
  */
31
35
  daemonKeyPair?: KeyPair;
36
+ binaryCiphertext?: boolean;
32
37
  }
38
+ export declare function base64EncryptedWireByteLength(plaintextBytes: number): number;
39
+ export declare function maxBase64EncryptedPlaintextByteLength(wireBytes: number): number;
33
40
  /**
34
41
  * Creates an encrypted channel as the initiator (client).
35
42
  *
@@ -65,6 +72,7 @@ export declare class EncryptedChannel {
65
72
  setState(state: ChannelState): void;
66
73
  private handleMessage;
67
74
  send(data: string | ArrayBuffer): Promise<void>;
75
+ outboundWireByteLength(data: string | ArrayBuffer): number;
68
76
  private flushPendingSends;
69
77
  private handleDaemonRehello;
70
78
  close(code?: number, reason?: string): void;
@@ -10,14 +10,23 @@ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
10
10
  function isRecord(value) {
11
11
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
12
  }
13
+ function isE2EECapabilities(value) {
14
+ return (value === undefined ||
15
+ (isRecord(value) &&
16
+ (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")));
17
+ }
13
18
  function isE2EEHelloMessage(value) {
14
19
  return (isRecord(value) &&
15
20
  value.type === "e2ee_hello" &&
16
21
  typeof value.key === "string" &&
17
- value.key.trim().length > 0);
22
+ value.key.trim().length > 0 &&
23
+ isE2EECapabilities(value.capabilities));
18
24
  }
19
25
  function isE2EEReadyMessage(value) {
20
- return isRecord(value) && value.type === "e2ee_ready";
26
+ return isRecord(value) && value.type === "e2ee_ready" && isE2EECapabilities(value.capabilities);
27
+ }
28
+ function supportsBinaryCiphertext(message) {
29
+ return message.capabilities?.binaryCiphertext === true;
21
30
  }
22
31
  function buildInvalidHelloError(rawText, parsed) {
23
32
  const parsedRecord = isRecord(parsed) ? parsed : null;
@@ -38,6 +47,13 @@ function buildInvalidHelloError(rawText, parsed) {
38
47
  const HANDSHAKE_RETRY_MS = 1000;
39
48
  const MAX_PENDING_SENDS = 200;
40
49
  const REHANDSHAKE_KEY_MISMATCH_CLOSE_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
+ }
41
57
  const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
42
58
  function hasUnref(timeout) {
43
59
  return (typeof timeout === "object" &&
@@ -61,7 +77,11 @@ export async function createClientChannel(transport, daemonPublicKeyB64, events
61
77
  const channel = new EncryptedChannel(transport, sharedKey, events);
62
78
  // Send e2ee_hello with our public key
63
79
  const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
64
- const hello = { type: "e2ee_hello", key: ourPublicKeyB64 };
80
+ const hello = {
81
+ type: "e2ee_hello",
82
+ key: ourPublicKeyB64,
83
+ capabilities: { binaryCiphertext: true },
84
+ };
65
85
  const helloText = JSON.stringify(hello);
66
86
  let retry = null;
67
87
  const emitSendError = (error) => {
@@ -70,7 +90,10 @@ export async function createClientChannel(transport, daemonPublicKeyB64, events
70
90
  };
71
91
  const sendHello = () => {
72
92
  try {
73
- transport.send(helloText);
93
+ const result = transport.send(helloText);
94
+ if (result) {
95
+ void result.catch(emitSendError);
96
+ }
74
97
  return true;
75
98
  }
76
99
  catch (error) {
@@ -113,9 +136,11 @@ export async function createClientChannel(transport, daemonPublicKeyB64, events
113
136
  export async function createDaemonChannel(transport, daemonKeyPair, events = {}) {
114
137
  return new Promise((resolve, reject) => {
115
138
  const bufferedMessages = [];
116
- const shouldIgnorePostHelloPlaintext = (data) => {
139
+ const shouldIgnorePostHelloPlaintext = (message) => {
117
140
  try {
118
- const text = typeof data === "string" ? data : new TextDecoder().decode(data);
141
+ if (message.isBinary)
142
+ return false;
143
+ const text = decodeTransportText(message.data);
119
144
  const parsed = JSON.parse(text);
120
145
  return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
121
146
  }
@@ -123,9 +148,12 @@ export async function createDaemonChannel(transport, daemonKeyPair, events = {})
123
148
  return false;
124
149
  }
125
150
  };
126
- const handleHello = async (data) => {
151
+ const handleHello = async (message) => {
127
152
  try {
128
- const helloText = typeof data === "string" ? data : new TextDecoder().decode(data);
153
+ if (message.isBinary) {
154
+ throw buildInvalidHelloError("<binary frame>");
155
+ }
156
+ const helloText = decodeTransportText(message.data);
129
157
  let parsed;
130
158
  try {
131
159
  parsed = JSON.parse(helloText);
@@ -147,8 +175,17 @@ export async function createDaemonChannel(transport, daemonKeyPair, events = {})
147
175
  Object.assign(transport, { onmessage: bufferNext });
148
176
  const clientPublicKey = importPublicKey(msg.key);
149
177
  const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
150
- const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair });
151
- transport.send(JSON.stringify({ type: "e2ee_ready" }));
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
+ });
152
189
  channel.setState("open");
153
190
  events.onopen?.();
154
191
  for (const buffered of bufferedMessages) {
@@ -187,7 +224,7 @@ export class EncryptedChannel {
187
224
  this.events = events;
188
225
  this.options = options;
189
226
  Object.assign(transport, {
190
- onmessage: (data) => this.handleMessage(data),
227
+ onmessage: (message) => this.handleMessage(message),
191
228
  onclose: (code, reason) => {
192
229
  this.state = "closed";
193
230
  this.events.onclose?.(code, reason);
@@ -202,17 +239,28 @@ export class EncryptedChannel {
202
239
  setState(state) {
203
240
  this.state = state;
204
241
  }
205
- async handleMessage(data) {
242
+ async handleMessage(message) {
206
243
  if (this.state === "handshaking") {
207
244
  try {
208
- const text = typeof data === "string" ? data : new TextDecoder().decode(data);
245
+ if (message.isBinary)
246
+ return;
247
+ const text = decodeTransportText(message.data);
209
248
  const parsed = JSON.parse(text);
210
249
  if (isE2EEReadyMessage(parsed)) {
250
+ this.options.binaryCiphertext = supportsBinaryCiphertext(parsed);
211
251
  this.state = "open";
212
252
  this.events.onopen?.();
213
253
  for (const cb of this.onOpenCallbacks)
214
254
  cb();
215
- await this.flushPendingSends();
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
+ }
216
264
  }
217
265
  }
218
266
  catch {
@@ -226,12 +274,14 @@ export class EncryptedChannel {
226
274
  const ciphertext = await (async () => {
227
275
  // Handle (or ignore) any stray plaintext handshake traffic.
228
276
  try {
229
- const text = typeof data === "string" ? data : new TextDecoder().decode(data);
277
+ if (message.isBinary)
278
+ throw new Error("not plaintext handshake traffic");
279
+ const text = decodeTransportText(message.data);
230
280
  if (text.trim().startsWith("{")) {
231
281
  const parsed = JSON.parse(text);
232
282
  if (isE2EEHelloMessage(parsed)) {
233
283
  if (this.options.daemonKeyPair) {
234
- await this.handleDaemonRehello(parsed.key);
284
+ await this.handleDaemonRehello(parsed);
235
285
  }
236
286
  return null;
237
287
  }
@@ -251,21 +301,31 @@ export class EncryptedChannel {
251
301
  // Otherwise ignore JSON parse/TextDecoder failures and fall back to
252
302
  // decoding ciphertext below.
253
303
  }
254
- if (typeof data === "string") {
255
- return base64ToArrayBuffer(data);
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
+ };
256
311
  }
257
- // Some WebSocket implementations deliver text frames as ArrayBuffer.
258
- // Our protocol always transmits ciphertext as base64 text.
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.
259
319
  try {
260
- const decoded = new TextDecoder().decode(data);
261
- return base64ToArrayBuffer(decoded);
320
+ return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
262
321
  }
263
322
  catch {
264
- return data;
323
+ return { data: requireArrayBuffer(message.data), isBinary: null };
265
324
  }
266
325
  })();
267
326
  if (ciphertext) {
268
- const plaintext = decrypt(this.sharedKey, ciphertext);
327
+ const plaintextBytes = decrypt(this.sharedKey, ciphertext.data);
328
+ const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary);
269
329
  this.events.onmessage?.(plaintext);
270
330
  }
271
331
  }
@@ -294,8 +354,21 @@ export class EncryptedChannel {
294
354
  throw new Error("Channel not open");
295
355
  }
296
356
  const ciphertext = encrypt(this.sharedKey, data);
297
- // Send as base64 for WebSocket text compatibility
298
- this.transport.send(arrayBufferToBase64(ciphertext));
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);
299
372
  }
300
373
  async flushPendingSends() {
301
374
  if (this.state !== "open")
@@ -306,16 +379,21 @@ export class EncryptedChannel {
306
379
  await this.send(item);
307
380
  }
308
381
  }
309
- async handleDaemonRehello(clientKeyB64) {
382
+ async handleDaemonRehello(message) {
310
383
  if (!this.options.daemonKeyPair)
311
384
  return;
312
- const clientPublicKey = importPublicKey(clientKeyB64);
385
+ const clientPublicKey = importPublicKey(message.key);
313
386
  const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
314
387
  // If it's the same client key (handshake retry), re-send
315
388
  // "ready" but do not re-key. Re-keying here would desync
316
389
  // the channel and cause decrypt failures.
317
390
  if (keysEqual(nextSharedKey, this.sharedKey)) {
318
- this.transport.send(JSON.stringify({ type: "e2ee_ready" }));
391
+ await this.transport.send(JSON.stringify({
392
+ type: "e2ee_ready",
393
+ ...(this.options.binaryCiphertext
394
+ ? { capabilities: { binaryCiphertext: true } }
395
+ : {}),
396
+ }));
319
397
  return;
320
398
  }
321
399
  // A different key on an already-open encrypted channel is not an
@@ -338,6 +416,32 @@ export class EncryptedChannel {
338
416
  this.onCloseCallbacks.push(cb);
339
417
  }
340
418
  }
419
+ function decodeTransportText(data) {
420
+ return typeof data === "string" ? data : new TextDecoder().decode(data);
421
+ }
422
+ function requireArrayBuffer(data) {
423
+ if (data instanceof ArrayBuffer)
424
+ return data;
425
+ throw new Error("Binary WebSocket frame did not contain bytes");
426
+ }
427
+ function decodeLegacyPlaintext(data) {
428
+ try {
429
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
430
+ }
431
+ catch {
432
+ return data;
433
+ }
434
+ }
435
+ function decodePlaintext(data, isBinary) {
436
+ if (isBinary === true)
437
+ return data;
438
+ if (isBinary === false)
439
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
440
+ return decodeLegacyPlaintext(data);
441
+ }
442
+ function utf8ByteLength(data) {
443
+ return typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength;
444
+ }
341
445
  function keysEqual(a, b) {
342
446
  if (a.byteLength !== b.byteLength)
343
447
  return false;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { ConnectionRole, RelaySessionAttachment } from "./types.js";
2
2
  export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
3
- export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
3
+ export { base64EncryptedWireByteLength, createClientChannel, createDaemonChannel, EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
4
4
  export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
5
5
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
2
- export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
2
+ export { base64EncryptedWireByteLength, createClientChannel, createDaemonChannel, EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
3
3
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpaseo/relay",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Paseo relay for bridging daemon and client connections",
5
5
  "files": [
6
6
  "dist",