@camerontaylor/paseo-relay 0.12.0-fork.1 → 0.13.0-fork.2
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/e2ee.d.ts +2 -0
- package/dist/e2ee.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/noise/cipher-state.d.ts +19 -0
- package/dist/noise/cipher-state.js +72 -0
- package/dist/noise/index.d.ts +45 -0
- package/dist/noise/index.js +179 -0
- package/dist/noise/symmetric-state.d.ts +15 -0
- package/dist/noise/symmetric-state.js +71 -0
- package/dist/protected-records.d.ts +34 -0
- package/dist/protected-records.js +179 -0
- package/dist/secure-channel.d.ts +87 -0
- package/dist/secure-channel.js +650 -0
- package/package.json +4 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Endpoint security for relay-carried daemon sessions.
|
|
4
|
+
*
|
|
5
|
+
* This is the one entrypoint both endpoints use to secure a transport. It owns
|
|
6
|
+
* the handshake, record protection, counters, retries, termination, and the
|
|
7
|
+
* negotiated security mode. Relay adapters own routing and physical I/O;
|
|
8
|
+
* daemon admission owns credentials and authorization.
|
|
9
|
+
*
|
|
10
|
+
* Two modes exist during migration:
|
|
11
|
+
*
|
|
12
|
+
* - "protected": conforming Noise_NK_25519_ChaChaPoly_SHA256 bound to the
|
|
13
|
+
* daemon's static key, with authenticated record framing. Handshake
|
|
14
|
+
* messages travel byte-exact (base64) inside the existing `e2ee_hello` /
|
|
15
|
+
* `e2ee_ready` JSON envelopes under `protected`.
|
|
16
|
+
* - "legacy": the pre-existing ephemeral-static box construction in
|
|
17
|
+
* `encrypted-channel.ts`. It has no replay protection. Chosen only when the
|
|
18
|
+
* peer omits `protected` (an older peer) and policy allows it.
|
|
19
|
+
*
|
|
20
|
+
* A present but invalid `protected` field never falls back to legacy.
|
|
21
|
+
*
|
|
22
|
+
* See docs/relay-endpoint-security.md for the byte-level mapping.
|
|
23
|
+
*/
|
|
24
|
+
import { EncryptedChannel, base64EncryptedWireByteLength, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
|
|
25
|
+
import { deriveSharedKey, exportPublicKey, generateKeyPair, importPublicKey, } from "./crypto.js";
|
|
26
|
+
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
|
|
27
|
+
import { createNoiseInitiator, createNoiseResponder, NoiseProtocolError, } from "./noise/index.js";
|
|
28
|
+
import { decodeProtectedRecord, encodeProtectedRecord, protectedRecordByteLength, } from "./protected-records.js";
|
|
29
|
+
/** Thrown by {@link openDaemonSecureChannel} when the handshake is refused; carries the close to send. */
|
|
30
|
+
export class SecureChannelRejectedError extends Error {
|
|
31
|
+
constructor(message, closeCode, closeReason) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.closeCode = closeCode;
|
|
34
|
+
this.closeReason = closeReason;
|
|
35
|
+
this.name = "SecureChannelRejectedError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export const PROTECTED_MODE_REQUIRED_CLOSE_CODE = 1008;
|
|
39
|
+
export const PROTECTED_MODE_REQUIRED_CLOSE_REASON = "protected endpoint security required";
|
|
40
|
+
/** Client-side close when a protected handshake fails. Browser-safe (4000-4999). */
|
|
41
|
+
export const PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE = 4002;
|
|
42
|
+
/**
|
|
43
|
+
* Client-side close when an inbound record fails authentication or framing.
|
|
44
|
+
* Browser WebSocket.close accepts only 1000 or 3000-4999, so a client must
|
|
45
|
+
* never try to send a 1011.
|
|
46
|
+
*/
|
|
47
|
+
export const PROTECTED_RECORD_FAILED_CLOSE_CODE = 4003;
|
|
48
|
+
/** Daemon-side close for the same failures; the daemon runs on Node's ws. */
|
|
49
|
+
const DAEMON_RECORD_FAILURE_CLOSE_CODE = 1011;
|
|
50
|
+
const REHANDSHAKE_REJECTION_CODE = 1008;
|
|
51
|
+
const REHANDSHAKE_MISMATCH_CLOSE_REASON = "E2EE re-handshake transcript mismatch";
|
|
52
|
+
const NOISE_PROLOGUE = new TextEncoder().encode("paseo-relay-e2ee/1");
|
|
53
|
+
const PROTECTED_ENVELOPE_VERSION = 1;
|
|
54
|
+
const PARAMS_VERSION = 0x01;
|
|
55
|
+
const PARAM_FLAG_BINARY_CIPHERTEXT = 0x01;
|
|
56
|
+
const PARAM_FLAGS_KNOWN = PARAM_FLAG_BINARY_CIPHERTEXT;
|
|
57
|
+
const HANDSHAKE_RETRY_MS = 1000;
|
|
58
|
+
const MAX_PENDING_SENDS = 200;
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Client
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
/**
|
|
63
|
+
* Secures a transport as the initiator. Resolves once the hello has been sent;
|
|
64
|
+
* `events.onopen` fires when the channel is usable. Sends queued before open
|
|
65
|
+
* are flushed in order after the handshake, capped at {@link MAX_PENDING_SENDS}.
|
|
66
|
+
*/
|
|
67
|
+
export async function openClientSecureChannel(transport, options) {
|
|
68
|
+
const events = options.events ?? {};
|
|
69
|
+
const minimumMode = options.minimumMode ?? "legacy";
|
|
70
|
+
const daemonPublicKey = importPublicKey(options.daemonPublicKeyB64);
|
|
71
|
+
// Separate ephemeral keys per protocol: the legacy box key is routing and
|
|
72
|
+
// compatibility input only; the Noise ephemeral lives inside the handshake.
|
|
73
|
+
const legacyKeyPair = generateKeyPair();
|
|
74
|
+
const legacySharedKey = deriveSharedKey(legacyKeyPair.secretKey, daemonPublicKey);
|
|
75
|
+
const noise = createNoiseInitiator({ remoteStatic: daemonPublicKey, prologue: NOISE_PROLOGUE });
|
|
76
|
+
const offered = { binaryCiphertext: true };
|
|
77
|
+
const msg1 = noise.writeMessage(encodeParams(offered));
|
|
78
|
+
const hello = {
|
|
79
|
+
type: "e2ee_hello",
|
|
80
|
+
key: exportPublicKey(legacyKeyPair.publicKey),
|
|
81
|
+
capabilities: { binaryCiphertext: true },
|
|
82
|
+
protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg1)) },
|
|
83
|
+
};
|
|
84
|
+
const helloText = JSON.stringify(hello);
|
|
85
|
+
const channel = new ClientSecureChannel(transport, events, {
|
|
86
|
+
minimumMode,
|
|
87
|
+
noise,
|
|
88
|
+
offered,
|
|
89
|
+
legacySharedKey,
|
|
90
|
+
});
|
|
91
|
+
let retry = null;
|
|
92
|
+
const emitSendError = (error) => {
|
|
93
|
+
events.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
|
94
|
+
};
|
|
95
|
+
const sendHello = () => {
|
|
96
|
+
try {
|
|
97
|
+
const result = transport.send(helloText);
|
|
98
|
+
if (result)
|
|
99
|
+
void result.catch(emitSendError);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// Daemon restarts move the socket through CLOSING/CLOSED; report, never throw from timers.
|
|
103
|
+
emitSendError(error);
|
|
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
|
+
unref(retry);
|
|
123
|
+
return channel;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Client channel: waits for the daemon's ready, selects the mode, then
|
|
127
|
+
* delegates records to the mode's implementation.
|
|
128
|
+
*/
|
|
129
|
+
class ClientSecureChannel {
|
|
130
|
+
constructor(transport, events, handshake) {
|
|
131
|
+
this.transport = transport;
|
|
132
|
+
this.events = events;
|
|
133
|
+
this.state = "handshaking";
|
|
134
|
+
this.closeNotified = false;
|
|
135
|
+
this.inner = null;
|
|
136
|
+
this.pendingSends = [];
|
|
137
|
+
this.onOpenCallbacks = [];
|
|
138
|
+
this.onCloseCallbacks = [];
|
|
139
|
+
this.handshake = handshake;
|
|
140
|
+
Object.assign(transport, {
|
|
141
|
+
onmessage: (message) => this.handleHandshakeMessage(message),
|
|
142
|
+
onclose: (code, reason) => this.handleTransportClose(code, reason),
|
|
143
|
+
onerror: (error) => this.events.onerror?.(error),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
handleHandshakeMessage(message) {
|
|
147
|
+
if (this.state !== "handshaking" || !this.handshake)
|
|
148
|
+
return;
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
if (message.isBinary)
|
|
152
|
+
return;
|
|
153
|
+
parsed = JSON.parse(decodeTransportText(message.data));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return; // not handshake traffic; ignore while handshaking
|
|
157
|
+
}
|
|
158
|
+
if (!isRecord(parsed) || parsed.type !== "e2ee_ready")
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
// A ready the daemon recognizably sent but that fails validation is a
|
|
162
|
+
// broken or tampered handshake, never unrelated traffic to wait past.
|
|
163
|
+
if (!isReadyEnvelope(parsed))
|
|
164
|
+
throw new NoiseProtocolError("malformed ready envelope");
|
|
165
|
+
const ready = parsed;
|
|
166
|
+
if (ready.protected !== undefined) {
|
|
167
|
+
this.openProtected(ready.protected);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
this.openLegacy(ready);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
175
|
+
this.events.onerror?.(err);
|
|
176
|
+
this.fail(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, err.message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
openProtected(envelope) {
|
|
180
|
+
const handshake = this.handshake;
|
|
181
|
+
if (envelope.v !== PROTECTED_ENVELOPE_VERSION) {
|
|
182
|
+
throw new NoiseProtocolError("unsupported protected envelope version");
|
|
183
|
+
}
|
|
184
|
+
const msg2 = new Uint8Array(base64ToArrayBuffer(envelope.message));
|
|
185
|
+
const selected = decodeParams(handshake.noise.readMessage(msg2));
|
|
186
|
+
if (selected.binaryCiphertext && !handshake.offered.binaryCiphertext) {
|
|
187
|
+
throw new NoiseProtocolError("daemon selected an unoffered parameter");
|
|
188
|
+
}
|
|
189
|
+
const keys = handshake.noise.split();
|
|
190
|
+
this.becomeOpen(new ProtectedChannel(this.transport, this.events, keys.send, keys.receive, selected, {
|
|
191
|
+
retry: null,
|
|
192
|
+
failureCloseCode: PROTECTED_RECORD_FAILED_CLOSE_CODE,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
openLegacy(ready) {
|
|
196
|
+
const handshake = this.handshake;
|
|
197
|
+
if (handshake.minimumMode === "protected") {
|
|
198
|
+
throw new Error("daemon offered only legacy endpoint security");
|
|
199
|
+
}
|
|
200
|
+
const legacy = new EncryptedChannel(this.transport, handshake.legacySharedKey, this.events, {
|
|
201
|
+
binaryCiphertext: ready.capabilities?.binaryCiphertext === true,
|
|
202
|
+
});
|
|
203
|
+
legacy.setState("open");
|
|
204
|
+
this.becomeOpen(new LegacyInner(legacy));
|
|
205
|
+
}
|
|
206
|
+
becomeOpen(inner) {
|
|
207
|
+
this.handshake = null;
|
|
208
|
+
this.inner = inner;
|
|
209
|
+
this.state = "open";
|
|
210
|
+
// The mode implementation now owns the transport callbacks; mirror its close.
|
|
211
|
+
inner.onClose(() => this.notifyClosed());
|
|
212
|
+
this.events.onopen?.();
|
|
213
|
+
for (const cb of this.onOpenCallbacks)
|
|
214
|
+
cb();
|
|
215
|
+
const pending = this.pendingSends;
|
|
216
|
+
this.pendingSends = [];
|
|
217
|
+
void (async () => {
|
|
218
|
+
try {
|
|
219
|
+
for (const item of pending)
|
|
220
|
+
await inner.send(item);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
224
|
+
this.events.onerror?.(err);
|
|
225
|
+
this.fail(PROTECTED_RECORD_FAILED_CLOSE_CODE, err.message);
|
|
226
|
+
}
|
|
227
|
+
})();
|
|
228
|
+
}
|
|
229
|
+
/** Transport closed while this wrapper still owned its callbacks (handshaking). */
|
|
230
|
+
handleTransportClose(code, reason) {
|
|
231
|
+
this.events.onclose?.(code, reason);
|
|
232
|
+
this.notifyClosed();
|
|
233
|
+
}
|
|
234
|
+
notifyClosed() {
|
|
235
|
+
this.state = "closed";
|
|
236
|
+
this.handshake = null;
|
|
237
|
+
if (this.closeNotified)
|
|
238
|
+
return;
|
|
239
|
+
this.closeNotified = true;
|
|
240
|
+
for (const cb of this.onCloseCallbacks)
|
|
241
|
+
cb();
|
|
242
|
+
}
|
|
243
|
+
fail(code, reason) {
|
|
244
|
+
if (this.state === "closed")
|
|
245
|
+
return;
|
|
246
|
+
this.state = "closed";
|
|
247
|
+
this.handshake = null;
|
|
248
|
+
try {
|
|
249
|
+
this.transport.close(code, reason);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// ignore
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async send(data) {
|
|
256
|
+
if (this.state === "handshaking") {
|
|
257
|
+
if (this.pendingSends.length >= MAX_PENDING_SENDS)
|
|
258
|
+
this.pendingSends.shift();
|
|
259
|
+
this.pendingSends.push(data);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (this.state !== "open" || !this.inner)
|
|
263
|
+
throw new Error("Channel not open");
|
|
264
|
+
await this.inner.send(data);
|
|
265
|
+
}
|
|
266
|
+
close(code = 1000, reason = "Normal closure") {
|
|
267
|
+
if (this.inner) {
|
|
268
|
+
this.inner.close(code, reason);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.transport.close(code, reason);
|
|
272
|
+
}
|
|
273
|
+
this.state = "closed";
|
|
274
|
+
this.handshake = null;
|
|
275
|
+
}
|
|
276
|
+
isOpen() {
|
|
277
|
+
return this.state === "open" && this.inner?.isOpen() === true;
|
|
278
|
+
}
|
|
279
|
+
securityMode() {
|
|
280
|
+
if (!this.inner)
|
|
281
|
+
throw new Error("security mode is unknown until the channel opens");
|
|
282
|
+
return this.inner.mode;
|
|
283
|
+
}
|
|
284
|
+
outboundWireByteLength(data) {
|
|
285
|
+
if (this.inner)
|
|
286
|
+
return this.inner.outboundWireByteLength(data);
|
|
287
|
+
// Mode unknown while handshaking: report the larger of the two so callers stay within limits.
|
|
288
|
+
const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength;
|
|
289
|
+
return Math.max(base64EncryptedWireByteLength(plaintextBytes), 4 * Math.ceil(protectedRecordByteLength(plaintextBytes) / 3));
|
|
290
|
+
}
|
|
291
|
+
onTransitionToOpen(cb) {
|
|
292
|
+
this.onOpenCallbacks.push(cb);
|
|
293
|
+
}
|
|
294
|
+
onClose(cb) {
|
|
295
|
+
this.onCloseCallbacks.push(cb);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Daemon
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
/**
|
|
302
|
+
* Secures a transport as the responder. Resolves once the peer's hello has
|
|
303
|
+
* been accepted and the ready sent. Rejects with
|
|
304
|
+
* {@link SecureChannelRejectedError} when policy refuses the peer, and with a
|
|
305
|
+
* plain error for malformed or failed handshakes; the caller closes the socket.
|
|
306
|
+
*/
|
|
307
|
+
export async function openDaemonSecureChannel(transport, options) {
|
|
308
|
+
const events = options.events ?? {};
|
|
309
|
+
const minimumMode = () => typeof options.minimumMode === "function"
|
|
310
|
+
? options.minimumMode()
|
|
311
|
+
: (options.minimumMode ?? "legacy");
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
const buffered = [];
|
|
314
|
+
const handleHello = async (message) => {
|
|
315
|
+
try {
|
|
316
|
+
if (message.isBinary)
|
|
317
|
+
throw new Error("Invalid hello message (binary frame)");
|
|
318
|
+
const helloText = decodeTransportText(message.data);
|
|
319
|
+
let parsed;
|
|
320
|
+
try {
|
|
321
|
+
parsed = JSON.parse(helloText);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
throw new Error(`Invalid hello message: ${preview(helloText)}`);
|
|
325
|
+
}
|
|
326
|
+
if (!isHelloEnvelope(parsed))
|
|
327
|
+
throw new Error(`Invalid hello message: ${preview(helloText)}`);
|
|
328
|
+
// Buffer traffic that arrives while the ready is in flight so an early
|
|
329
|
+
// record is not mistaken for a second hello.
|
|
330
|
+
Object.assign(transport, { onmessage: (next) => buffered.push(next) });
|
|
331
|
+
const channel = parsed.protected !== undefined
|
|
332
|
+
? await acceptProtectedHello(transport, events, options.daemonKeyPair, parsed, helloText)
|
|
333
|
+
: await acceptLegacyHello(transport, events, options.daemonKeyPair, parsed, minimumMode());
|
|
334
|
+
events.onopen?.();
|
|
335
|
+
// The channel now owns the transport: buffered records are decoded and a
|
|
336
|
+
// buffered hello retry gets the same exact-transcript answer as a live one.
|
|
337
|
+
for (const next of buffered)
|
|
338
|
+
transport.onmessage?.(next);
|
|
339
|
+
resolve(channel);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
reject(error);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
Object.assign(transport, {
|
|
346
|
+
onmessage: handleHello,
|
|
347
|
+
onerror: (error) => reject(error),
|
|
348
|
+
onclose: (code, reason) => reject(new Error(`Connection closed during handshake: ${code} ${reason}`)),
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function acceptProtectedHello(transport, events, daemonKeyPair, hello, helloText) {
|
|
353
|
+
const envelope = hello.protected;
|
|
354
|
+
if (envelope.v !== PROTECTED_ENVELOPE_VERSION) {
|
|
355
|
+
throw new NoiseProtocolError("unsupported protected envelope version");
|
|
356
|
+
}
|
|
357
|
+
const noise = createNoiseResponder({ localStatic: daemonKeyPair, prologue: NOISE_PROLOGUE });
|
|
358
|
+
const msg1 = new Uint8Array(base64ToArrayBuffer(envelope.message));
|
|
359
|
+
const offered = decodeParams(noise.readMessage(msg1));
|
|
360
|
+
const selected = { binaryCiphertext: offered.binaryCiphertext };
|
|
361
|
+
const msg2 = noise.writeMessage(encodeParams(selected));
|
|
362
|
+
const ready = {
|
|
363
|
+
type: "e2ee_ready",
|
|
364
|
+
protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg2)) },
|
|
365
|
+
};
|
|
366
|
+
const readyText = JSON.stringify(ready);
|
|
367
|
+
await transport.send(readyText);
|
|
368
|
+
const keys = noise.split();
|
|
369
|
+
return new ProtectedChannel(transport, events, keys.send, keys.receive, selected, {
|
|
370
|
+
retry: { helloText, readyText },
|
|
371
|
+
failureCloseCode: DAEMON_RECORD_FAILURE_CLOSE_CODE,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function acceptLegacyHello(transport, events, daemonKeyPair, hello, minimumMode) {
|
|
375
|
+
if (minimumMode === "protected") {
|
|
376
|
+
throw new SecureChannelRejectedError("client offered only legacy endpoint security", PROTECTED_MODE_REQUIRED_CLOSE_CODE, PROTECTED_MODE_REQUIRED_CLOSE_REASON);
|
|
377
|
+
}
|
|
378
|
+
const clientPublicKey = importPublicKey(hello.key);
|
|
379
|
+
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
|
|
380
|
+
const binaryCiphertext = hello.capabilities?.binaryCiphertext === true;
|
|
381
|
+
await transport.send(JSON.stringify({
|
|
382
|
+
type: "e2ee_ready",
|
|
383
|
+
...(binaryCiphertext ? { capabilities: { binaryCiphertext: true } } : {}),
|
|
384
|
+
}));
|
|
385
|
+
const legacy = new EncryptedChannel(transport, sharedKey, events, {
|
|
386
|
+
daemonKeyPair,
|
|
387
|
+
binaryCiphertext,
|
|
388
|
+
});
|
|
389
|
+
legacy.setState("open");
|
|
390
|
+
return new LegacyInner(legacy);
|
|
391
|
+
}
|
|
392
|
+
class LegacyInner {
|
|
393
|
+
constructor(legacy) {
|
|
394
|
+
this.legacy = legacy;
|
|
395
|
+
this.mode = "legacy";
|
|
396
|
+
}
|
|
397
|
+
send(data) {
|
|
398
|
+
return this.legacy.send(data);
|
|
399
|
+
}
|
|
400
|
+
close(code, reason) {
|
|
401
|
+
this.legacy.close(code, reason);
|
|
402
|
+
}
|
|
403
|
+
isOpen() {
|
|
404
|
+
return this.legacy.isOpen();
|
|
405
|
+
}
|
|
406
|
+
securityMode() {
|
|
407
|
+
return this.mode;
|
|
408
|
+
}
|
|
409
|
+
outboundWireByteLength(data) {
|
|
410
|
+
return this.legacy.outboundWireByteLength(data);
|
|
411
|
+
}
|
|
412
|
+
onTransitionToOpen(cb) {
|
|
413
|
+
this.legacy.onTransitionToOpen(cb);
|
|
414
|
+
}
|
|
415
|
+
onClose(cb) {
|
|
416
|
+
this.legacy.onClose(cb);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Protected records over an open transport. Every inbound record is
|
|
421
|
+
* authenticated and framing-checked before delivery; any failure closes the
|
|
422
|
+
* channel and clears the keys.
|
|
423
|
+
*/
|
|
424
|
+
class ProtectedChannel {
|
|
425
|
+
constructor(transport, events, sender, receiver, params, role) {
|
|
426
|
+
this.transport = transport;
|
|
427
|
+
this.events = events;
|
|
428
|
+
this.sender = sender;
|
|
429
|
+
this.receiver = receiver;
|
|
430
|
+
this.params = params;
|
|
431
|
+
this.role = role;
|
|
432
|
+
this.mode = "protected";
|
|
433
|
+
this.state = "open";
|
|
434
|
+
this.onCloseCallbacks = [];
|
|
435
|
+
Object.assign(transport, {
|
|
436
|
+
onmessage: (message) => this.handleMessage(message),
|
|
437
|
+
onclose: (code, reason) => {
|
|
438
|
+
this.markClosed();
|
|
439
|
+
this.events.onclose?.(code, reason);
|
|
440
|
+
},
|
|
441
|
+
onerror: (error) => this.events.onerror?.(error),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async handleMessage(message) {
|
|
445
|
+
if (this.state !== "open")
|
|
446
|
+
return;
|
|
447
|
+
try {
|
|
448
|
+
const handshake = !message.isBinary ? parseHandshakeEnvelope(message.data) : null;
|
|
449
|
+
if (handshake) {
|
|
450
|
+
await this.handleHandshakeTraffic(handshake);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const record = message.isBinary
|
|
454
|
+
? new Uint8Array(requireArrayBuffer(message.data))
|
|
455
|
+
: new Uint8Array(base64ToArrayBuffer(decodeTransportText(message.data)));
|
|
456
|
+
const frame = decodeProtectedRecord(record, this.receiver);
|
|
457
|
+
const plaintext = frame.kind === "text"
|
|
458
|
+
? new TextDecoder("utf-8", { fatal: true }).decode(frame.data)
|
|
459
|
+
: toArrayBuffer(frame.data);
|
|
460
|
+
this.events.onmessage?.(plaintext);
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
464
|
+
this.fail(this.role.failureCloseCode, err.message);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async handleHandshakeTraffic(handshake) {
|
|
468
|
+
if (handshake.kind === "ready")
|
|
469
|
+
return; // client ignores duplicate ready after open
|
|
470
|
+
if (!this.role.retry)
|
|
471
|
+
return; // a client never answers hellos
|
|
472
|
+
if (handshake.text === this.role.retry.helloText) {
|
|
473
|
+
await this.transport.send(this.role.retry.readyText);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.fail(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_MISMATCH_CLOSE_REASON);
|
|
477
|
+
}
|
|
478
|
+
async send(data) {
|
|
479
|
+
if (this.state !== "open")
|
|
480
|
+
throw new Error("Channel not open");
|
|
481
|
+
const kind = typeof data === "string" ? "text" : "binary";
|
|
482
|
+
const frame = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
483
|
+
let wire;
|
|
484
|
+
try {
|
|
485
|
+
// Synchronous up to here: the nonce is reserved before any await.
|
|
486
|
+
wire = encodeProtectedRecord(kind, frame, this.sender);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
490
|
+
this.fail(this.role.failureCloseCode, err.message);
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
if (this.params.binaryCiphertext && kind === "binary") {
|
|
494
|
+
await this.transport.send(toArrayBuffer(wire));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
await this.transport.send(arrayBufferToBase64(toArrayBuffer(wire)));
|
|
498
|
+
}
|
|
499
|
+
outboundWireByteLength(data) {
|
|
500
|
+
const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength;
|
|
501
|
+
const wireBytes = protectedRecordByteLength(plaintextBytes);
|
|
502
|
+
if (this.params.binaryCiphertext && typeof data !== "string")
|
|
503
|
+
return wireBytes;
|
|
504
|
+
return 4 * Math.ceil(wireBytes / 3);
|
|
505
|
+
}
|
|
506
|
+
close(code = 1000, reason = "Normal closure") {
|
|
507
|
+
this.markClosed();
|
|
508
|
+
this.transport.close(code, reason);
|
|
509
|
+
}
|
|
510
|
+
fail(code, reason) {
|
|
511
|
+
if (this.state !== "open")
|
|
512
|
+
return;
|
|
513
|
+
this.markClosed();
|
|
514
|
+
try {
|
|
515
|
+
this.transport.close(code, reason);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// ignore
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
markClosed() {
|
|
522
|
+
if (this.state === "closed")
|
|
523
|
+
return;
|
|
524
|
+
this.state = "closed";
|
|
525
|
+
this.sender.clear();
|
|
526
|
+
this.receiver.clear();
|
|
527
|
+
for (const cb of this.onCloseCallbacks)
|
|
528
|
+
cb();
|
|
529
|
+
}
|
|
530
|
+
isOpen() {
|
|
531
|
+
return this.state === "open";
|
|
532
|
+
}
|
|
533
|
+
securityMode() {
|
|
534
|
+
return this.mode;
|
|
535
|
+
}
|
|
536
|
+
onTransitionToOpen(cb) {
|
|
537
|
+
cb();
|
|
538
|
+
}
|
|
539
|
+
onClose(cb) {
|
|
540
|
+
this.onCloseCallbacks.push(cb);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Largest plaintext that fits in `wireBytes` as a base64 text frame in either
|
|
545
|
+
* mode. Callers size payloads before the mode is known, so take the tighter bound.
|
|
546
|
+
*/
|
|
547
|
+
export function maxSecuredPlaintextByteLength(wireBytes) {
|
|
548
|
+
const legacy = maxBase64EncryptedPlaintextByteLength(wireBytes);
|
|
549
|
+
const base64Capacity = Math.floor(wireBytes / 4) * 3;
|
|
550
|
+
let protectedBound = base64Capacity;
|
|
551
|
+
while (protectedBound > 0 && protectedRecordByteLength(protectedBound) > base64Capacity) {
|
|
552
|
+
protectedBound = base64Capacity - (protectedRecordByteLength(protectedBound) - protectedBound);
|
|
553
|
+
}
|
|
554
|
+
return Math.max(0, Math.min(legacy, protectedBound));
|
|
555
|
+
}
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Envelope parsing and parameter encoding
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
function isRecord(value) {
|
|
560
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
561
|
+
}
|
|
562
|
+
function isCapabilities(value) {
|
|
563
|
+
return (value === undefined ||
|
|
564
|
+
(isRecord(value) &&
|
|
565
|
+
(value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")));
|
|
566
|
+
}
|
|
567
|
+
function isProtectedEnvelope(value) {
|
|
568
|
+
return (isRecord(value) &&
|
|
569
|
+
typeof value.v === "number" &&
|
|
570
|
+
typeof value.message === "string" &&
|
|
571
|
+
value.message.length > 0);
|
|
572
|
+
}
|
|
573
|
+
function isHelloEnvelope(value) {
|
|
574
|
+
return (isRecord(value) &&
|
|
575
|
+
value.type === "e2ee_hello" &&
|
|
576
|
+
typeof value.key === "string" &&
|
|
577
|
+
value.key.trim().length > 0 &&
|
|
578
|
+
isCapabilities(value.capabilities) &&
|
|
579
|
+
(value.protected === undefined || isProtectedEnvelope(value.protected)));
|
|
580
|
+
}
|
|
581
|
+
function isReadyEnvelope(value) {
|
|
582
|
+
return (isRecord(value) &&
|
|
583
|
+
value.type === "e2ee_ready" &&
|
|
584
|
+
isCapabilities(value.capabilities) &&
|
|
585
|
+
(value.protected === undefined || isProtectedEnvelope(value.protected)));
|
|
586
|
+
}
|
|
587
|
+
/** Classifies plaintext handshake traffic seen on an open channel; null for anything else. */
|
|
588
|
+
function parseHandshakeEnvelope(data) {
|
|
589
|
+
let text;
|
|
590
|
+
try {
|
|
591
|
+
text = decodeTransportText(data);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
if (!text.trim().startsWith("{"))
|
|
597
|
+
return null;
|
|
598
|
+
let parsed;
|
|
599
|
+
try {
|
|
600
|
+
parsed = JSON.parse(text);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
throw new Error("Received plaintext frame on encrypted channel");
|
|
604
|
+
}
|
|
605
|
+
if (isRecord(parsed) && parsed.type === "e2ee_hello")
|
|
606
|
+
return { kind: "hello", text };
|
|
607
|
+
if (isRecord(parsed) && parsed.type === "e2ee_ready")
|
|
608
|
+
return { kind: "ready" };
|
|
609
|
+
// Any other JSON is plaintext app traffic: the peer is not encrypting.
|
|
610
|
+
throw new Error("Received plaintext frame on encrypted channel");
|
|
611
|
+
}
|
|
612
|
+
function encodeParams(params) {
|
|
613
|
+
return new Uint8Array([
|
|
614
|
+
PARAMS_VERSION,
|
|
615
|
+
params.binaryCiphertext ? PARAM_FLAG_BINARY_CIPHERTEXT : 0,
|
|
616
|
+
]);
|
|
617
|
+
}
|
|
618
|
+
function decodeParams(payload) {
|
|
619
|
+
if (payload.byteLength !== 2)
|
|
620
|
+
throw new NoiseProtocolError("invalid parameter payload");
|
|
621
|
+
if (payload[0] !== PARAMS_VERSION)
|
|
622
|
+
throw new NoiseProtocolError("unsupported parameter version");
|
|
623
|
+
if ((payload[1] & ~PARAM_FLAGS_KNOWN) !== 0)
|
|
624
|
+
throw new NoiseProtocolError("reserved parameter flag set");
|
|
625
|
+
return { binaryCiphertext: (payload[1] & PARAM_FLAG_BINARY_CIPHERTEXT) !== 0 };
|
|
626
|
+
}
|
|
627
|
+
function preview(text) {
|
|
628
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
629
|
+
return JSON.stringify(compact.length > 160 ? `${compact.slice(0, 157)}...` : compact);
|
|
630
|
+
}
|
|
631
|
+
function decodeTransportText(data) {
|
|
632
|
+
return typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
633
|
+
}
|
|
634
|
+
function requireArrayBuffer(data) {
|
|
635
|
+
if (data instanceof ArrayBuffer)
|
|
636
|
+
return data;
|
|
637
|
+
throw new Error("Binary WebSocket frame did not contain bytes");
|
|
638
|
+
}
|
|
639
|
+
function toArrayBuffer(bytes) {
|
|
640
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
641
|
+
}
|
|
642
|
+
function utf8ByteLength(text) {
|
|
643
|
+
return new TextEncoder().encode(text).byteLength;
|
|
644
|
+
}
|
|
645
|
+
function unref(timer) {
|
|
646
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) {
|
|
647
|
+
timer.unref();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=secure-channel.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camerontaylor/paseo-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0-fork.2",
|
|
4
4
|
"description": "Paseo relay for bridging daemon and client connections",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"test:watch": "vitest"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
+
"@noble/ciphers": "^2.4.0",
|
|
39
|
+
"@noble/curves": "^2.4.0",
|
|
40
|
+
"@noble/hashes": "^2.4.0",
|
|
38
41
|
"base64-js": "^1.5.1",
|
|
39
42
|
"tweetnacl": "^1.0.3",
|
|
40
43
|
"ws": "^8.14.2"
|