@getpaseo/relay 0.2.2 → 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 +3 -3
- package/dist/crypto.js +3 -9
- package/dist/e2ee.d.ts +1 -1
- package/dist/encrypted-channel.d.ts +8 -2
- package/dist/encrypted-channel.js +125 -29
- package/package.json +1 -1
package/dist/crypto.d.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* Bundle format (binary):
|
|
8
8
|
* [nonce (24 bytes)] [ciphertext...]
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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):
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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: ((
|
|
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 = {
|
|
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 = (
|
|
132
|
+
const shouldIgnorePostHelloPlaintext = (message) => {
|
|
117
133
|
try {
|
|
118
|
-
|
|
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 (
|
|
144
|
+
const handleHello = async (message) => {
|
|
127
145
|
try {
|
|
128
|
-
|
|
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
|
|
151
|
-
transport.send(JSON.stringify({
|
|
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: (
|
|
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(
|
|
235
|
+
async handleMessage(message) {
|
|
206
236
|
if (this.state === "handshaking") {
|
|
207
237
|
try {
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
255
|
-
return
|
|
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
|
-
//
|
|
258
|
-
//
|
|
310
|
+
// Older transport adapters could lose the opcode. Retain the former
|
|
311
|
+
// base64-first behavior only in the legacy path.
|
|
259
312
|
try {
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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(
|
|
374
|
+
async handleDaemonRehello(message) {
|
|
310
375
|
if (!this.options.daemonKeyPair)
|
|
311
376
|
return;
|
|
312
|
-
const clientPublicKey = importPublicKey(
|
|
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({
|
|
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;
|