@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.
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +17 -0
- package/dist/cloudflare-adapter.d.ts +76 -0
- package/dist/cloudflare-adapter.js +504 -0
- package/dist/crypto.d.ts +30 -0
- package/dist/crypto.js +145 -0
- package/dist/cutover-proxy.d.ts +6 -0
- package/dist/cutover-proxy.js +12 -0
- package/dist/e2ee.d.ts +5 -0
- package/dist/e2ee.js +3 -0
- package/dist/encrypted-channel.d.ts +86 -0
- package/dist/encrypted-channel.js +452 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +11 -0
- package/package.json +54 -0
|
@@ -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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|