@getpaseo/relay 0.2.1 → 0.2.3

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,6 +33,7 @@ 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
  }
33
38
  /**
34
39
  * Creates an encrypted channel as the initiator (client).
@@ -65,6 +70,7 @@ export declare class EncryptedChannel {
65
70
  setState(state: ChannelState): void;
66
71
  private handleMessage;
67
72
  send(data: string | ArrayBuffer): Promise<void>;
73
+ outboundWireByteLength(data: string | ArrayBuffer): number;
68
74
  private flushPendingSends;
69
75
  private handleDaemonRehello;
70
76
  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;
@@ -61,7 +70,11 @@ export async function createClientChannel(transport, daemonPublicKeyB64, events
61
70
  const channel = new EncryptedChannel(transport, sharedKey, events);
62
71
  // Send e2ee_hello with our public key
63
72
  const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey);
64
- const hello = { type: "e2ee_hello", key: ourPublicKeyB64 };
73
+ const hello = {
74
+ type: "e2ee_hello",
75
+ key: ourPublicKeyB64,
76
+ capabilities: { binaryCiphertext: true },
77
+ };
65
78
  const helloText = JSON.stringify(hello);
66
79
  let retry = null;
67
80
  const emitSendError = (error) => {
@@ -70,7 +83,10 @@ export async function createClientChannel(transport, daemonPublicKeyB64, events
70
83
  };
71
84
  const sendHello = () => {
72
85
  try {
73
- transport.send(helloText);
86
+ const result = transport.send(helloText);
87
+ if (result) {
88
+ void result.catch(emitSendError);
89
+ }
74
90
  return true;
75
91
  }
76
92
  catch (error) {
@@ -113,9 +129,11 @@ export async function createClientChannel(transport, daemonPublicKeyB64, events
113
129
  export async function createDaemonChannel(transport, daemonKeyPair, events = {}) {
114
130
  return new Promise((resolve, reject) => {
115
131
  const bufferedMessages = [];
116
- const shouldIgnorePostHelloPlaintext = (data) => {
132
+ const shouldIgnorePostHelloPlaintext = (message) => {
117
133
  try {
118
- const text = typeof data === "string" ? data : new TextDecoder().decode(data);
134
+ if (message.isBinary)
135
+ return false;
136
+ const text = decodeTransportText(message.data);
119
137
  const parsed = JSON.parse(text);
120
138
  return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed);
121
139
  }
@@ -123,9 +141,12 @@ export async function createDaemonChannel(transport, daemonKeyPair, events = {})
123
141
  return false;
124
142
  }
125
143
  };
126
- const handleHello = async (data) => {
144
+ const handleHello = async (message) => {
127
145
  try {
128
- const helloText = typeof data === "string" ? data : new TextDecoder().decode(data);
146
+ if (message.isBinary) {
147
+ throw buildInvalidHelloError("<binary frame>");
148
+ }
149
+ const helloText = decodeTransportText(message.data);
129
150
  let parsed;
130
151
  try {
131
152
  parsed = JSON.parse(helloText);
@@ -147,8 +168,17 @@ export async function createDaemonChannel(transport, daemonKeyPair, events = {})
147
168
  Object.assign(transport, { onmessage: bufferNext });
148
169
  const clientPublicKey = importPublicKey(msg.key);
149
170
  const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
150
- const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair });
151
- transport.send(JSON.stringify({ type: "e2ee_ready" }));
171
+ const binaryCiphertext = supportsBinaryCiphertext(msg);
172
+ await transport.send(JSON.stringify({
173
+ type: "e2ee_ready",
174
+ ...(binaryCiphertext
175
+ ? { capabilities: { binaryCiphertext: true } }
176
+ : {}),
177
+ }));
178
+ const channel = new EncryptedChannel(transport, sharedKey, events, {
179
+ daemonKeyPair,
180
+ binaryCiphertext,
181
+ });
152
182
  channel.setState("open");
153
183
  events.onopen?.();
154
184
  for (const buffered of bufferedMessages) {
@@ -187,7 +217,7 @@ export class EncryptedChannel {
187
217
  this.events = events;
188
218
  this.options = options;
189
219
  Object.assign(transport, {
190
- onmessage: (data) => this.handleMessage(data),
220
+ onmessage: (message) => this.handleMessage(message),
191
221
  onclose: (code, reason) => {
192
222
  this.state = "closed";
193
223
  this.events.onclose?.(code, reason);
@@ -202,17 +232,28 @@ export class EncryptedChannel {
202
232
  setState(state) {
203
233
  this.state = state;
204
234
  }
205
- async handleMessage(data) {
235
+ async handleMessage(message) {
206
236
  if (this.state === "handshaking") {
207
237
  try {
208
- const text = typeof data === "string" ? data : new TextDecoder().decode(data);
238
+ if (message.isBinary)
239
+ return;
240
+ const text = decodeTransportText(message.data);
209
241
  const parsed = JSON.parse(text);
210
242
  if (isE2EEReadyMessage(parsed)) {
243
+ this.options.binaryCiphertext = supportsBinaryCiphertext(parsed);
211
244
  this.state = "open";
212
245
  this.events.onopen?.();
213
246
  for (const cb of this.onOpenCallbacks)
214
247
  cb();
215
- await this.flushPendingSends();
248
+ try {
249
+ await this.flushPendingSends();
250
+ }
251
+ catch (error) {
252
+ const err = error instanceof Error ? error : new Error(String(error));
253
+ this.events.onerror?.(err);
254
+ this.state = "closed";
255
+ this.transport.close(1011, err.message);
256
+ }
216
257
  }
217
258
  }
218
259
  catch {
@@ -226,12 +267,14 @@ export class EncryptedChannel {
226
267
  const ciphertext = await (async () => {
227
268
  // Handle (or ignore) any stray plaintext handshake traffic.
228
269
  try {
229
- const text = typeof data === "string" ? data : new TextDecoder().decode(data);
270
+ if (message.isBinary)
271
+ throw new Error("not plaintext handshake traffic");
272
+ const text = decodeTransportText(message.data);
230
273
  if (text.trim().startsWith("{")) {
231
274
  const parsed = JSON.parse(text);
232
275
  if (isE2EEHelloMessage(parsed)) {
233
276
  if (this.options.daemonKeyPair) {
234
- await this.handleDaemonRehello(parsed.key);
277
+ await this.handleDaemonRehello(parsed);
235
278
  }
236
279
  return null;
237
280
  }
@@ -251,21 +294,31 @@ export class EncryptedChannel {
251
294
  // Otherwise ignore JSON parse/TextDecoder failures and fall back to
252
295
  // decoding ciphertext below.
253
296
  }
254
- if (typeof data === "string") {
255
- return base64ToArrayBuffer(data);
297
+ if (this.options.binaryCiphertext) {
298
+ return message.isBinary
299
+ ? { data: requireArrayBuffer(message.data), isBinary: true }
300
+ : {
301
+ data: base64ToArrayBuffer(decodeTransportText(message.data)),
302
+ isBinary: false,
303
+ };
304
+ }
305
+ // COMPAT(binaryCiphertext): added in v0.2.3, remove legacy base64-only
306
+ // receive mode after 2027-01-27.
307
+ if (!message.isBinary) {
308
+ return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
256
309
  }
257
- // Some WebSocket implementations deliver text frames as ArrayBuffer.
258
- // Our protocol always transmits ciphertext as base64 text.
310
+ // Older transport adapters could lose the opcode. Retain the former
311
+ // base64-first behavior only in the legacy path.
259
312
  try {
260
- const decoded = new TextDecoder().decode(data);
261
- return base64ToArrayBuffer(decoded);
313
+ return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null };
262
314
  }
263
315
  catch {
264
- return data;
316
+ return { data: requireArrayBuffer(message.data), isBinary: null };
265
317
  }
266
318
  })();
267
319
  if (ciphertext) {
268
- const plaintext = decrypt(this.sharedKey, ciphertext);
320
+ const plaintextBytes = decrypt(this.sharedKey, ciphertext.data);
321
+ const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary);
269
322
  this.events.onmessage?.(plaintext);
270
323
  }
271
324
  }
@@ -294,8 +347,20 @@ export class EncryptedChannel {
294
347
  throw new Error("Channel not open");
295
348
  }
296
349
  const ciphertext = encrypt(this.sharedKey, data);
297
- // Send as base64 for WebSocket text compatibility
298
- this.transport.send(arrayBufferToBase64(ciphertext));
350
+ if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
351
+ await this.transport.send(ciphertext);
352
+ return;
353
+ }
354
+ // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends
355
+ // after 2027-01-27 once the supported peer floor includes negotiation.
356
+ await this.transport.send(arrayBufferToBase64(ciphertext));
357
+ }
358
+ outboundWireByteLength(data) {
359
+ const encryptedBytes = utf8ByteLength(data) + 40;
360
+ if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
361
+ return encryptedBytes;
362
+ }
363
+ return 4 * Math.ceil(encryptedBytes / 3);
299
364
  }
300
365
  async flushPendingSends() {
301
366
  if (this.state !== "open")
@@ -306,16 +371,21 @@ export class EncryptedChannel {
306
371
  await this.send(item);
307
372
  }
308
373
  }
309
- async handleDaemonRehello(clientKeyB64) {
374
+ async handleDaemonRehello(message) {
310
375
  if (!this.options.daemonKeyPair)
311
376
  return;
312
- const clientPublicKey = importPublicKey(clientKeyB64);
377
+ const clientPublicKey = importPublicKey(message.key);
313
378
  const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
314
379
  // If it's the same client key (handshake retry), re-send
315
380
  // "ready" but do not re-key. Re-keying here would desync
316
381
  // the channel and cause decrypt failures.
317
382
  if (keysEqual(nextSharedKey, this.sharedKey)) {
318
- this.transport.send(JSON.stringify({ type: "e2ee_ready" }));
383
+ await this.transport.send(JSON.stringify({
384
+ type: "e2ee_ready",
385
+ ...(this.options.binaryCiphertext
386
+ ? { capabilities: { binaryCiphertext: true } }
387
+ : {}),
388
+ }));
319
389
  return;
320
390
  }
321
391
  // A different key on an already-open encrypted channel is not an
@@ -338,6 +408,32 @@ export class EncryptedChannel {
338
408
  this.onCloseCallbacks.push(cb);
339
409
  }
340
410
  }
411
+ function decodeTransportText(data) {
412
+ return typeof data === "string" ? data : new TextDecoder().decode(data);
413
+ }
414
+ function requireArrayBuffer(data) {
415
+ if (data instanceof ArrayBuffer)
416
+ return data;
417
+ throw new Error("Binary WebSocket frame did not contain bytes");
418
+ }
419
+ function decodeLegacyPlaintext(data) {
420
+ try {
421
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
422
+ }
423
+ catch {
424
+ return data;
425
+ }
426
+ }
427
+ function decodePlaintext(data, isBinary) {
428
+ if (isBinary === true)
429
+ return data;
430
+ if (isBinary === false)
431
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
432
+ return decodeLegacyPlaintext(data);
433
+ }
434
+ function utf8ByteLength(data) {
435
+ return typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength;
436
+ }
341
437
  function keysEqual(a, b) {
342
438
  if (a.byteLength !== b.byteLength)
343
439
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpaseo/relay",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Paseo relay for bridging daemon and client connections",
5
5
  "files": [
6
6
  "dist",