@acpantenna/relay-protocol 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/bip39-english.d.ts +28 -0
- package/dist/bip39-english.d.ts.map +1 -0
- package/dist/bip39-english.js +28 -0
- package/dist/bip39-english.js.map +1 -0
- package/dist/bytes.d.ts +21 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +114 -0
- package/dist/bytes.js.map +1 -0
- package/dist/frames.d.ts +90 -0
- package/dist/frames.d.ts.map +1 -0
- package/dist/frames.js +234 -0
- package/dist/frames.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +44 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +120 -0
- package/dist/link.js.map +1 -0
- package/dist/noise.d.ts +181 -0
- package/dist/noise.d.ts.map +1 -0
- package/dist/noise.js +490 -0
- package/dist/noise.js.map +1 -0
- package/dist/relay.d.ts +37 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +70 -0
- package/dist/relay.js.map +1 -0
- package/dist/sas.d.ts +11 -0
- package/dist/sas.d.ts.map +1 -0
- package/dist/sas.js +26 -0
- package/dist/sas.js.map +1 -0
- package/dist/session.d.ts +298 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +608 -0
- package/dist/session.js.map +1 -0
- package/package.json +56 -5
- package/src/bip39-english.ts +30 -0
- package/src/bytes.ts +125 -0
- package/src/frames.ts +278 -0
- package/src/index.ts +14 -0
- package/src/link.ts +160 -0
- package/src/noise.ts +655 -0
- package/src/relay.ts +106 -0
- package/src/sas.ts +28 -0
- package/src/session.ts +848 -0
- package/test-vectors/frames.json +353 -0
- package/test-vectors/noise-kk.json +43 -0
- package/test-vectors/noise-nkpsk0.json +47 -0
- package/test-vectors/sas.json +21 -0
- package/test-vectors/session.json +58 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One end of an encrypted channel over a relay: the Noise handshake, then
|
|
3
|
+
* framed ACP messages. Both sides use it — the browser as the
|
|
4
|
+
* {@link initiator}, the agent side as the {@link responder} — to pair with
|
|
5
|
+
* a link, and a remembered browser and the agent side to reconnect
|
|
6
|
+
* ({@link reconnectInitiator}, {@link reconnectResponder}).
|
|
7
|
+
*
|
|
8
|
+
* The session does no I/O of its own. Give it `send`, which puts one binary
|
|
9
|
+
* message on the relay socket, and hand every binary message from the socket
|
|
10
|
+
* to `receive`. When the socket ends, call `transportClosed`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
base64urlDecode,
|
|
15
|
+
base64urlEncode,
|
|
16
|
+
equalBytes,
|
|
17
|
+
utf8Decode,
|
|
18
|
+
utf8Encode,
|
|
19
|
+
} from "./bytes.ts";
|
|
20
|
+
import {
|
|
21
|
+
chunkMessage,
|
|
22
|
+
controlFrame,
|
|
23
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
24
|
+
decodeFrame,
|
|
25
|
+
encodeFrame,
|
|
26
|
+
type Frame,
|
|
27
|
+
FrameError,
|
|
28
|
+
FrameType,
|
|
29
|
+
Reassembler,
|
|
30
|
+
} from "./frames.ts";
|
|
31
|
+
import { type PairingLink, prologue } from "./link.ts";
|
|
32
|
+
import {
|
|
33
|
+
type CipherState,
|
|
34
|
+
DHLEN,
|
|
35
|
+
type HandshakeResult,
|
|
36
|
+
HandshakeState,
|
|
37
|
+
type KeyPair,
|
|
38
|
+
KKHandshakeState,
|
|
39
|
+
MAX_MESSAGE_LENGTH,
|
|
40
|
+
NoiseError,
|
|
41
|
+
type StaticKey,
|
|
42
|
+
staticKey,
|
|
43
|
+
} from "./noise.ts";
|
|
44
|
+
import { sasWords } from "./sas.ts";
|
|
45
|
+
|
|
46
|
+
/** Each side rekeys its sending CipherState after this many messages, and its receiving one likewise. */
|
|
47
|
+
export const REKEY_INTERVAL = 10_000;
|
|
48
|
+
/** How much of what it sent each side keeps by default, to resend after a reconnect. */
|
|
49
|
+
export const DEFAULT_RESUME_BYTES = 8 * 1024 * 1024;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What one side keeps across the transports of a connection, so a reconnect
|
|
53
|
+
* loses nothing: ACP stream ids go on counting from one transport to the
|
|
54
|
+
* next, and the messages most recently sent are kept to send again.
|
|
55
|
+
*/
|
|
56
|
+
export class ResumeBuffer {
|
|
57
|
+
/** The last stream received whole from the other side. */
|
|
58
|
+
received = 0;
|
|
59
|
+
private next = 1;
|
|
60
|
+
private kept: { stream: number; message: string }[] = [];
|
|
61
|
+
private keptBytes = 0;
|
|
62
|
+
|
|
63
|
+
constructor(private readonly maxBytes = DEFAULT_RESUME_BYTES) {}
|
|
64
|
+
|
|
65
|
+
/** The stream id of the next message sent. */
|
|
66
|
+
get nextStream(): number {
|
|
67
|
+
return this.next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Numbers a message to send and keeps it, dropping the oldest past the limit. */
|
|
71
|
+
add(message: string): number {
|
|
72
|
+
const stream = this.next++;
|
|
73
|
+
this.kept.push({ stream, message });
|
|
74
|
+
this.keptBytes += message.length;
|
|
75
|
+
while (this.keptBytes > this.maxBytes && this.kept.length > 0) {
|
|
76
|
+
this.keptBytes -= (
|
|
77
|
+
this.kept.shift() as { message: string }
|
|
78
|
+
).message.length;
|
|
79
|
+
}
|
|
80
|
+
return stream;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The messages sent after stream `stream`, which the other side says it
|
|
85
|
+
* received last; undefined if some are no longer kept (or never were).
|
|
86
|
+
*/
|
|
87
|
+
after(stream: number): { stream: number; message: string }[] | undefined {
|
|
88
|
+
if (!Number.isInteger(stream) || stream < 0 || stream >= this.next) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (stream === this.next - 1) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const first = this.kept[0];
|
|
95
|
+
if (!first || first.stream > stream + 1) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return this.kept.filter((entry) => entry.stream > stream);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Starts again from stream 1 both ways: nothing before is resent. */
|
|
102
|
+
reset(): void {
|
|
103
|
+
this.received = 0;
|
|
104
|
+
this.next = 1;
|
|
105
|
+
this.kept = [];
|
|
106
|
+
this.keptBytes = 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type SessionState =
|
|
111
|
+
/** The initiator has not called `start` yet. */
|
|
112
|
+
| "idle"
|
|
113
|
+
/** The handshake is under way. */
|
|
114
|
+
| "handshake"
|
|
115
|
+
/**
|
|
116
|
+
* Keys confirmed (the initiator's first frame, a PING, has decrypted);
|
|
117
|
+
* waiting for the user to confirm the SAS words on the agent side, or,
|
|
118
|
+
* reconnecting, for the agent side's CONFIRMED.
|
|
119
|
+
*/
|
|
120
|
+
| "pairing"
|
|
121
|
+
/** Confirmed: ACP messages flow both ways. */
|
|
122
|
+
| "open"
|
|
123
|
+
| "closed";
|
|
124
|
+
|
|
125
|
+
export interface HandshakeInfo {
|
|
126
|
+
/** The four SAS words both sides show. */
|
|
127
|
+
sas: string[];
|
|
128
|
+
/** The final handshake hash `h`. */
|
|
129
|
+
handshakeHash: Uint8Array;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type CloseEvent =
|
|
133
|
+
/** An authenticated CLOSE was sent or received: the session ended on purpose. */
|
|
134
|
+
| { kind: "ended"; reason: string; byPeer: boolean }
|
|
135
|
+
/** The socket ended without an authenticated CLOSE. Report it as "connection lost". */
|
|
136
|
+
| { kind: "lost"; reason: string }
|
|
137
|
+
/** A rule was broken or a message failed to decrypt. The socket must be closed. */
|
|
138
|
+
| { kind: "error"; reason: string; error: Error };
|
|
139
|
+
|
|
140
|
+
export interface SessionHandlers {
|
|
141
|
+
/**
|
|
142
|
+
* Show `sas` to the user. The initiator calls it when the handshake
|
|
143
|
+
* finishes, the responder only once the keys are confirmed.
|
|
144
|
+
*/
|
|
145
|
+
onHandshake?: (info: HandshakeInfo) => void;
|
|
146
|
+
/**
|
|
147
|
+
* Responder only: the first transport message from the initiator decrypted,
|
|
148
|
+
* so the peer really holds the keys (key confirmation). Only now is the
|
|
149
|
+
* link's psk used up. Reconnecting, the session is already open by then:
|
|
150
|
+
* there are no words, so the responder sends CONFIRMED by itself.
|
|
151
|
+
*/
|
|
152
|
+
onKeyConfirmed?: () => void;
|
|
153
|
+
/** Initiator only: the agent side sent CONFIRMED; `send` may now be used. */
|
|
154
|
+
onConfirmed?: () => void;
|
|
155
|
+
/**
|
|
156
|
+
* Responder only, after a pairing: the browser sent its static public key
|
|
157
|
+
* (REMEMBER), to reconnect with later.
|
|
158
|
+
*/
|
|
159
|
+
onRemember?: (publicKey: Uint8Array) => void;
|
|
160
|
+
/** One whole ACP message (a JSON-RPC 2.0 message, as text). */
|
|
161
|
+
onMessage?: (message: string) => void;
|
|
162
|
+
/** A PONG answered a PING. */
|
|
163
|
+
onPong?: (payload: Uint8Array) => void;
|
|
164
|
+
/** The session is over, for one of the reasons in {@link CloseEvent}. */
|
|
165
|
+
onClose?: (event: CloseEvent) => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface SessionOptions extends SessionHandlers {
|
|
169
|
+
/** Puts one binary message on the relay socket. */
|
|
170
|
+
send: (data: Uint8Array) => void;
|
|
171
|
+
/** The largest ACP message accepted from the peer. Defaults to 32 MiB. */
|
|
172
|
+
maxMessageBytes?: number;
|
|
173
|
+
/** Fixes the ephemeral key, for tests only. */
|
|
174
|
+
ephemeralPrivateKey?: Uint8Array;
|
|
175
|
+
/**
|
|
176
|
+
* Kept across the transports of one connection: give a reconnect the
|
|
177
|
+
* buffer the connection's earlier sessions used. A new one by default.
|
|
178
|
+
*/
|
|
179
|
+
buffer?: ResumeBuffer;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface InitiatorOptions extends SessionOptions {
|
|
183
|
+
/** The parsed pairing link. Check its expiry first. */
|
|
184
|
+
link: PairingLink;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ReconnectInitiatorOptions extends SessionOptions {
|
|
188
|
+
room: Uint8Array;
|
|
189
|
+
webOrigin: string;
|
|
190
|
+
/** The agent side's static key, from the link the browser paired with. */
|
|
191
|
+
remoteStaticPublicKey: Uint8Array;
|
|
192
|
+
/** The browser's own static key, whose public half it sent in REMEMBER. */
|
|
193
|
+
staticKey: StaticKey;
|
|
194
|
+
/**
|
|
195
|
+
* Ask to carry on where the last transport stopped, with `buffer`'s
|
|
196
|
+
* streams. False for a page that has lost them (a reload): both sides
|
|
197
|
+
* start again from stream 1, and the browser loads the session afresh.
|
|
198
|
+
*/
|
|
199
|
+
resume: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ReconnectResponderOptions extends SessionOptions {
|
|
203
|
+
staticKeyPair: KeyPair;
|
|
204
|
+
room: Uint8Array;
|
|
205
|
+
webOrigin: string;
|
|
206
|
+
/** The browser's static key, from its REMEMBER. */
|
|
207
|
+
remoteStaticPublicKey: Uint8Array;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ResponderOptions extends SessionOptions {
|
|
211
|
+
staticKeyPair: KeyPair;
|
|
212
|
+
psk: Uint8Array;
|
|
213
|
+
room: Uint8Array;
|
|
214
|
+
webOrigin: string;
|
|
215
|
+
/**
|
|
216
|
+
* Called with the initiator's ephemeral key once message 1 has decrypted.
|
|
217
|
+
* Return false if it has been seen before while the link is valid: the
|
|
218
|
+
* message is a replay, and the session fails.
|
|
219
|
+
*/
|
|
220
|
+
acceptEphemeral?: (ephemeralPublicKey: Uint8Array) => boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export class SessionError extends Error {
|
|
224
|
+
constructor(message: string) {
|
|
225
|
+
super(message);
|
|
226
|
+
this.name = "SessionError";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const EMPTY = new Uint8Array(0);
|
|
231
|
+
|
|
232
|
+
/** Either handshake: NKpsk0 steps are synchronous, KK steps asynchronous. */
|
|
233
|
+
interface Handshake {
|
|
234
|
+
writeMessage(payload: Uint8Array): Uint8Array | Promise<Uint8Array>;
|
|
235
|
+
readMessage(message: Uint8Array): Uint8Array | Promise<Uint8Array>;
|
|
236
|
+
readonly result: HandshakeResult | undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Calls `next` with `value` now if it is ready, else once it resolves. */
|
|
240
|
+
function then<T>(
|
|
241
|
+
value: T | Promise<T>,
|
|
242
|
+
next: (value: T) => void | Promise<void>,
|
|
243
|
+
): void | Promise<void> {
|
|
244
|
+
return value instanceof Promise ? value.then(next) : next(value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One end of the channel. Use {@link initiator} or {@link responder} to make one. */
|
|
248
|
+
export class RelaySession implements SessionHandlers {
|
|
249
|
+
readonly role: "initiator" | "responder";
|
|
250
|
+
/** A first pairing with a link, or a remembered browser coming back. */
|
|
251
|
+
readonly kind: "pairing" | "reconnect";
|
|
252
|
+
/** What this side keeps across the connection's transports. */
|
|
253
|
+
readonly buffer: ResumeBuffer;
|
|
254
|
+
onHandshake: SessionHandlers["onHandshake"];
|
|
255
|
+
onKeyConfirmed: SessionHandlers["onKeyConfirmed"];
|
|
256
|
+
onConfirmed: SessionHandlers["onConfirmed"];
|
|
257
|
+
onMessage: SessionHandlers["onMessage"];
|
|
258
|
+
onPong: SessionHandlers["onPong"];
|
|
259
|
+
onRemember: SessionHandlers["onRemember"];
|
|
260
|
+
onClose: SessionHandlers["onClose"];
|
|
261
|
+
|
|
262
|
+
private stateValue: SessionState;
|
|
263
|
+
private readonly handshake: Handshake;
|
|
264
|
+
private readonly sendRaw: (data: Uint8Array) => void;
|
|
265
|
+
private readonly acceptEphemeral:
|
|
266
|
+
| ((ephemeralPublicKey: Uint8Array) => boolean)
|
|
267
|
+
| undefined;
|
|
268
|
+
private reassembler: Reassembler;
|
|
269
|
+
private readonly maxMessageBytes: number;
|
|
270
|
+
private readonly askResume: boolean;
|
|
271
|
+
private sendCipher: CipherState | undefined;
|
|
272
|
+
private receiveCipher: CipherState | undefined;
|
|
273
|
+
private keyConfirmed = false;
|
|
274
|
+
private resumedValue = false;
|
|
275
|
+
private info: HandshakeInfo | undefined;
|
|
276
|
+
/** REMEMBER has been sent or received. */
|
|
277
|
+
private remembered = false;
|
|
278
|
+
/** An ACP message has gone, or come, since CONFIRMED: too late for REMEMBER. */
|
|
279
|
+
private acpSeen = false;
|
|
280
|
+
/** A handshake step is waiting on an asynchronous DH. */
|
|
281
|
+
private busy = false;
|
|
282
|
+
/** What arrived meanwhile, in order. */
|
|
283
|
+
private readonly backlog: Uint8Array[] = [];
|
|
284
|
+
|
|
285
|
+
constructor(
|
|
286
|
+
role: "initiator" | "responder",
|
|
287
|
+
kind: "pairing" | "reconnect",
|
|
288
|
+
handshake: Handshake,
|
|
289
|
+
options: SessionOptions & {
|
|
290
|
+
acceptEphemeral?: ResponderOptions["acceptEphemeral"];
|
|
291
|
+
resume?: boolean;
|
|
292
|
+
},
|
|
293
|
+
) {
|
|
294
|
+
this.role = role;
|
|
295
|
+
this.kind = kind;
|
|
296
|
+
this.buffer = options.buffer ?? new ResumeBuffer();
|
|
297
|
+
this.askResume = options.resume ?? false;
|
|
298
|
+
if (kind === "pairing" || (role === "initiator" && !this.askResume)) {
|
|
299
|
+
this.buffer.reset();
|
|
300
|
+
}
|
|
301
|
+
this.handshake = handshake;
|
|
302
|
+
this.sendRaw = options.send;
|
|
303
|
+
this.acceptEphemeral = options.acceptEphemeral;
|
|
304
|
+
this.maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
305
|
+
this.reassembler = new Reassembler(
|
|
306
|
+
this.maxMessageBytes,
|
|
307
|
+
this.buffer.received,
|
|
308
|
+
);
|
|
309
|
+
this.stateValue = role === "initiator" ? "idle" : "handshake";
|
|
310
|
+
this.onHandshake = options.onHandshake;
|
|
311
|
+
this.onKeyConfirmed = options.onKeyConfirmed;
|
|
312
|
+
this.onConfirmed = options.onConfirmed;
|
|
313
|
+
this.onMessage = options.onMessage;
|
|
314
|
+
this.onPong = options.onPong;
|
|
315
|
+
this.onRemember = options.onRemember;
|
|
316
|
+
this.onClose = options.onClose;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
get state(): SessionState {
|
|
320
|
+
return this.stateValue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A reconnect that carried on from the last transport: nothing was lost.
|
|
325
|
+
* False after a pairing, or a reconnect that started again from stream 1,
|
|
326
|
+
* when the browser must load the session afresh. Set before
|
|
327
|
+
* `onConfirmed` and `onKeyConfirmed` run.
|
|
328
|
+
*/
|
|
329
|
+
get resumed(): boolean {
|
|
330
|
+
return this.resumedValue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The SAS words and handshake hash, once the handshake is done (and, on the agent side, the keys confirmed). */
|
|
334
|
+
get handshakeInfo(): HandshakeInfo | undefined {
|
|
335
|
+
return this.stateValue === "handshake" ? undefined : this.info;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Initiator: sends message 1. */
|
|
339
|
+
start(): void {
|
|
340
|
+
if (this.role !== "initiator" || this.stateValue !== "idle") {
|
|
341
|
+
throw new SessionError("Only an idle initiator can start");
|
|
342
|
+
}
|
|
343
|
+
this.stateValue = "handshake";
|
|
344
|
+
this.run(() =>
|
|
345
|
+
then(this.handshake.writeMessage(EMPTY), (message) => {
|
|
346
|
+
if (this.stateValue === "handshake") {
|
|
347
|
+
this.sendRaw(message);
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Responder: the user confirmed the SAS words. Sends CONFIRMED, and lets
|
|
355
|
+
* ACP messages through from now on.
|
|
356
|
+
*/
|
|
357
|
+
confirm(): void {
|
|
358
|
+
if (
|
|
359
|
+
this.role !== "responder" ||
|
|
360
|
+
this.kind !== "pairing" ||
|
|
361
|
+
this.stateValue !== "pairing"
|
|
362
|
+
) {
|
|
363
|
+
throw new SessionError("Only a responder that is pairing can confirm");
|
|
364
|
+
}
|
|
365
|
+
this.sendFrame(controlFrame(FrameType.CONFIRMED));
|
|
366
|
+
this.stateValue = "open";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Sends one ACP message (a JSON-RPC 2.0 message, as text). */
|
|
370
|
+
send(message: string): void {
|
|
371
|
+
if (this.stateValue !== "open") {
|
|
372
|
+
throw new SessionError(`Cannot send while ${this.stateValue}`);
|
|
373
|
+
}
|
|
374
|
+
this.acpSeen = true;
|
|
375
|
+
this.sendStream(this.buffer.add(message), message);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private sendStream(stream: number, message: string): void {
|
|
379
|
+
for (const frame of chunkMessage(stream, utf8Encode(message))) {
|
|
380
|
+
this.sendFrame(frame);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Initiator, after a pairing: sends REMEMBER with the public half of the
|
|
386
|
+
* static key it will reconnect with. Once, as soon as CONFIRMED has come,
|
|
387
|
+
* before any ACP message.
|
|
388
|
+
*/
|
|
389
|
+
remember(publicKey: Uint8Array): void {
|
|
390
|
+
if (
|
|
391
|
+
this.role !== "initiator" ||
|
|
392
|
+
this.kind !== "pairing" ||
|
|
393
|
+
this.stateValue !== "open" ||
|
|
394
|
+
this.remembered ||
|
|
395
|
+
this.acpSeen
|
|
396
|
+
) {
|
|
397
|
+
throw new SessionError(
|
|
398
|
+
"REMEMBER goes once, right after CONFIRMED of a pairing",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (publicKey.length !== DHLEN) {
|
|
402
|
+
throw new SessionError("A static key is 32 bytes");
|
|
403
|
+
}
|
|
404
|
+
this.remembered = true;
|
|
405
|
+
this.sendFrame(
|
|
406
|
+
controlFrame(FrameType.REMEMBER, utf8Encode(base64urlEncode(publicKey))),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
ping(payload: Uint8Array = EMPTY): void {
|
|
411
|
+
if (this.stateValue !== "pairing" && this.stateValue !== "open") {
|
|
412
|
+
throw new SessionError(`Cannot ping while ${this.stateValue}`);
|
|
413
|
+
}
|
|
414
|
+
this.sendFrame(controlFrame(FrameType.PING, payload));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Ends the session with an authenticated CLOSE. */
|
|
418
|
+
close(reason = ""): void {
|
|
419
|
+
if (this.stateValue === "closed") {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (this.sendCipher) {
|
|
423
|
+
this.sendFrame(controlFrame(FrameType.CLOSE, utf8Encode(reason)));
|
|
424
|
+
}
|
|
425
|
+
this.finish({ kind: "ended", reason, byPeer: false });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The socket ended. Without an authenticated CLOSE first, the connection was lost. */
|
|
429
|
+
transportClosed(reason = "connection lost"): void {
|
|
430
|
+
if (this.stateValue !== "closed") {
|
|
431
|
+
this.finish({ kind: "lost", reason });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Takes one binary message from the relay socket. */
|
|
436
|
+
receive(data: Uint8Array): void {
|
|
437
|
+
if (this.stateValue === "closed") {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (this.busy) {
|
|
441
|
+
this.backlog.push(data);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
this.run(() => this.receiveUnsafe(data));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Runs one step. A step that waits on a DH holds back what arrives
|
|
449
|
+
* meanwhile, which then runs in order.
|
|
450
|
+
*/
|
|
451
|
+
private run(step: () => void | Promise<void>): void {
|
|
452
|
+
let pending: void | Promise<void>;
|
|
453
|
+
try {
|
|
454
|
+
pending = step();
|
|
455
|
+
} catch (error) {
|
|
456
|
+
this.failOn(error, true);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (!pending) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.busy = true;
|
|
463
|
+
pending.then(
|
|
464
|
+
() => this.resume(),
|
|
465
|
+
(error: unknown) => {
|
|
466
|
+
this.failOn(error, false);
|
|
467
|
+
this.resume();
|
|
468
|
+
},
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private resume(): void {
|
|
473
|
+
this.busy = false;
|
|
474
|
+
while (!this.busy && this.backlog.length > 0) {
|
|
475
|
+
const next = this.backlog.shift() as Uint8Array;
|
|
476
|
+
this.receive(next);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Protocol errors end the session; others are bugs, thrown when they can be. */
|
|
481
|
+
private failOn(error: unknown, rethrow: boolean): void {
|
|
482
|
+
if (
|
|
483
|
+
error instanceof NoiseError ||
|
|
484
|
+
error instanceof FrameError ||
|
|
485
|
+
error instanceof SessionError ||
|
|
486
|
+
!rethrow
|
|
487
|
+
) {
|
|
488
|
+
if (this.stateValue !== "closed") {
|
|
489
|
+
this.fail(
|
|
490
|
+
error instanceof Error ? error : new SessionError(String(error)),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private receiveUnsafe(data: Uint8Array): void | Promise<void> {
|
|
499
|
+
if (data.length > MAX_MESSAGE_LENGTH) {
|
|
500
|
+
throw new SessionError("Message longer than a Noise message");
|
|
501
|
+
}
|
|
502
|
+
switch (this.stateValue) {
|
|
503
|
+
case "idle":
|
|
504
|
+
throw new SessionError("Message before the handshake started");
|
|
505
|
+
case "handshake":
|
|
506
|
+
if (this.receiveCipher) {
|
|
507
|
+
// Responder: message 2 is out; this is the browser's first frame.
|
|
508
|
+
this.receiveTransport(data);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
return this.receiveHandshake(data);
|
|
512
|
+
default:
|
|
513
|
+
this.receiveTransport(data);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private receiveHandshake(data: Uint8Array): void | Promise<void> {
|
|
518
|
+
return then(this.handshake.readMessage(data), (payload) => {
|
|
519
|
+
if (payload.length > 0) {
|
|
520
|
+
throw new SessionError("Handshake payloads are empty");
|
|
521
|
+
}
|
|
522
|
+
if (this.role !== "responder") {
|
|
523
|
+
return this.handshakeDone(undefined);
|
|
524
|
+
}
|
|
525
|
+
const ephemeral = data.slice(0, 32);
|
|
526
|
+
if (this.acceptEphemeral && !this.acceptEphemeral(ephemeral)) {
|
|
527
|
+
throw new SessionError("Replayed handshake message");
|
|
528
|
+
}
|
|
529
|
+
return then(this.handshake.writeMessage(EMPTY), (reply) =>
|
|
530
|
+
this.handshakeDone(reply),
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Message 2 is written (responder, `reply`) or read (initiator). */
|
|
536
|
+
private handshakeDone(reply: Uint8Array | undefined): void {
|
|
537
|
+
if (this.stateValue !== "handshake") {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const result = this.handshake.result;
|
|
541
|
+
if (!result) {
|
|
542
|
+
throw new SessionError("Handshake did not finish");
|
|
543
|
+
}
|
|
544
|
+
// Keys first: the peer may answer before `send` returns.
|
|
545
|
+
this.sendCipher = result.send;
|
|
546
|
+
this.receiveCipher = result.receive;
|
|
547
|
+
this.info = {
|
|
548
|
+
sas: sasWords(result.handshakeHash),
|
|
549
|
+
handshakeHash: result.handshakeHash,
|
|
550
|
+
};
|
|
551
|
+
if (reply) {
|
|
552
|
+
// The words wait for key confirmation: a replayed message 1 must not
|
|
553
|
+
// put them in front of the user.
|
|
554
|
+
this.sendRaw(reply);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.stateValue = "pairing";
|
|
558
|
+
// The first transport frame is a PING, so the agent side can confirm
|
|
559
|
+
// the keys before it shows the words. Reconnecting, it carries the
|
|
560
|
+
// last stream received, to carry on from.
|
|
561
|
+
this.sendFrame(
|
|
562
|
+
controlFrame(
|
|
563
|
+
FrameType.PING,
|
|
564
|
+
this.askResume ? u32(this.buffer.received) : EMPTY,
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
if (this.stateValue === "pairing" && this.kind === "pairing") {
|
|
568
|
+
this.onHandshake?.(this.info);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private receiveTransport(data: Uint8Array): void {
|
|
573
|
+
const cipher = this.receiveCipher as CipherState;
|
|
574
|
+
const plaintext = cipher.decryptWithAd(EMPTY, data);
|
|
575
|
+
if (cipher.messages % REKEY_INTERVAL === 0) {
|
|
576
|
+
cipher.rekey();
|
|
577
|
+
}
|
|
578
|
+
const frame = decodeFrame(plaintext);
|
|
579
|
+
if (this.role === "responder" && !this.keyConfirmed) {
|
|
580
|
+
if (frame.type !== FrameType.PING) {
|
|
581
|
+
throw new SessionError("The first frame from the browser is a PING");
|
|
582
|
+
}
|
|
583
|
+
this.keyConfirmed = true;
|
|
584
|
+
if (this.kind === "reconnect") {
|
|
585
|
+
// Both static keys are known: no words to confirm.
|
|
586
|
+
this.openReconnect(frame.payload);
|
|
587
|
+
if (this.stateValue === "open") {
|
|
588
|
+
this.onKeyConfirmed?.();
|
|
589
|
+
}
|
|
590
|
+
if (this.stateValue === "open") {
|
|
591
|
+
this.handleFrame(frame);
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
this.stateValue = "pairing";
|
|
596
|
+
this.onKeyConfirmed?.();
|
|
597
|
+
if (this.stateValue !== "pairing") {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
this.onHandshake?.(this.info as HandshakeInfo);
|
|
601
|
+
if (this.stateValue !== "pairing") {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
this.handleFrame(frame);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private handleFrame(frame: Frame): void {
|
|
609
|
+
switch (frame.type) {
|
|
610
|
+
case FrameType.ACP: {
|
|
611
|
+
if (this.stateValue !== "open") {
|
|
612
|
+
throw new SessionError("ACP message before the SAS was confirmed");
|
|
613
|
+
}
|
|
614
|
+
this.acpSeen = true;
|
|
615
|
+
const message = this.reassembler.push(frame);
|
|
616
|
+
if (message) {
|
|
617
|
+
this.buffer.received = frame.stream;
|
|
618
|
+
this.onMessage?.(decodeText(message));
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
case FrameType.CLOSE:
|
|
623
|
+
this.reassembler.end();
|
|
624
|
+
this.finish({
|
|
625
|
+
kind: "ended",
|
|
626
|
+
reason: decodeText(frame.payload),
|
|
627
|
+
byPeer: true,
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
case FrameType.PING:
|
|
631
|
+
this.sendFrame(controlFrame(FrameType.PONG, frame.payload));
|
|
632
|
+
return;
|
|
633
|
+
case FrameType.PONG:
|
|
634
|
+
this.onPong?.(frame.payload);
|
|
635
|
+
return;
|
|
636
|
+
case FrameType.CONFIRMED:
|
|
637
|
+
if (this.role !== "initiator" || this.stateValue !== "pairing") {
|
|
638
|
+
throw new SessionError("Unexpected CONFIRMED");
|
|
639
|
+
}
|
|
640
|
+
if (this.kind === "reconnect") {
|
|
641
|
+
this.confirmReconnect(frame.payload);
|
|
642
|
+
} else if (frame.payload.length > 0) {
|
|
643
|
+
throw new SessionError("CONFIRMED of a pairing is empty");
|
|
644
|
+
} else {
|
|
645
|
+
this.stateValue = "open";
|
|
646
|
+
}
|
|
647
|
+
this.onConfirmed?.();
|
|
648
|
+
return;
|
|
649
|
+
case FrameType.REMEMBER: {
|
|
650
|
+
if (
|
|
651
|
+
this.role !== "responder" ||
|
|
652
|
+
this.kind !== "pairing" ||
|
|
653
|
+
this.stateValue !== "open" ||
|
|
654
|
+
this.remembered ||
|
|
655
|
+
this.acpSeen
|
|
656
|
+
) {
|
|
657
|
+
throw new SessionError("Unexpected REMEMBER");
|
|
658
|
+
}
|
|
659
|
+
let key: Uint8Array;
|
|
660
|
+
try {
|
|
661
|
+
key = base64urlDecode(decodeText(frame.payload));
|
|
662
|
+
} catch {
|
|
663
|
+
throw new FrameError("REMEMBER carries a 32-byte key in base64url");
|
|
664
|
+
}
|
|
665
|
+
if (key.length !== DHLEN) {
|
|
666
|
+
throw new FrameError("REMEMBER carries a 32-byte key in base64url");
|
|
667
|
+
}
|
|
668
|
+
this.remembered = true;
|
|
669
|
+
this.onRemember?.(key);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Responder, reconnecting: the browser's first PING has decrypted. Its
|
|
677
|
+
* payload, if any, is the last stream it received. Answers CONFIRMED with
|
|
678
|
+
* the last stream received here, then sends again what the browser
|
|
679
|
+
* missed; or, if that can't be done, an empty CONFIRMED, and both sides
|
|
680
|
+
* start again from stream 1.
|
|
681
|
+
*/
|
|
682
|
+
private openReconnect(payload: Uint8Array): void {
|
|
683
|
+
if (payload.length !== 0 && payload.length !== 4) {
|
|
684
|
+
throw new SessionError("A reconnect's PING is empty, or a u32");
|
|
685
|
+
}
|
|
686
|
+
const missed =
|
|
687
|
+
payload.length === 4 ? this.buffer.after(readU32(payload)) : undefined;
|
|
688
|
+
if (!missed) {
|
|
689
|
+
this.restart();
|
|
690
|
+
this.sendFrame(controlFrame(FrameType.CONFIRMED));
|
|
691
|
+
this.stateValue = "open";
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
this.sendFrame(
|
|
695
|
+
controlFrame(FrameType.CONFIRMED, u32(this.buffer.received)),
|
|
696
|
+
);
|
|
697
|
+
this.resumedValue = true;
|
|
698
|
+
this.stateValue = "open";
|
|
699
|
+
for (const { stream, message } of missed) {
|
|
700
|
+
this.sendStream(stream, message);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Initiator, reconnecting: the agent side's CONFIRMED. */
|
|
705
|
+
private confirmReconnect(payload: Uint8Array): void {
|
|
706
|
+
if (payload.length === 0) {
|
|
707
|
+
this.restart();
|
|
708
|
+
this.stateValue = "open";
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!this.askResume) {
|
|
712
|
+
throw new SessionError("CONFIRMED resumes what was not asked for");
|
|
713
|
+
}
|
|
714
|
+
const missed = this.buffer.after(readU32(payload));
|
|
715
|
+
if (!missed) {
|
|
716
|
+
throw new SessionError(
|
|
717
|
+
"Cannot resume: messages the agent side missed are gone",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
this.resumedValue = true;
|
|
721
|
+
this.stateValue = "open";
|
|
722
|
+
for (const { stream, message } of missed) {
|
|
723
|
+
this.sendStream(stream, message);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private restart(): void {
|
|
728
|
+
this.buffer.reset();
|
|
729
|
+
this.reassembler = new Reassembler(this.maxMessageBytes, 0);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private sendFrame(frame: Frame): void {
|
|
733
|
+
const cipher = this.sendCipher;
|
|
734
|
+
if (!cipher) {
|
|
735
|
+
throw new SessionError("No transport keys yet");
|
|
736
|
+
}
|
|
737
|
+
const ciphertext = cipher.encryptWithAd(EMPTY, encodeFrame(frame));
|
|
738
|
+
if (cipher.messages % REKEY_INTERVAL === 0) {
|
|
739
|
+
cipher.rekey();
|
|
740
|
+
}
|
|
741
|
+
this.sendRaw(ciphertext);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private fail(error: Error): void {
|
|
745
|
+
this.finish({ kind: "error", reason: error.message, error });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private finish(event: CloseEvent): void {
|
|
749
|
+
this.stateValue = "closed";
|
|
750
|
+
this.sendCipher = undefined;
|
|
751
|
+
this.receiveCipher = undefined;
|
|
752
|
+
this.onClose?.(event);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function u32(value: number): Uint8Array {
|
|
757
|
+
const out = new Uint8Array(4);
|
|
758
|
+
new DataView(out.buffer).setUint32(0, value);
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function readU32(bytes: Uint8Array): number {
|
|
763
|
+
return new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function decodeText(bytes: Uint8Array): string {
|
|
767
|
+
try {
|
|
768
|
+
return utf8Decode(bytes);
|
|
769
|
+
} catch {
|
|
770
|
+
throw new FrameError("Invalid UTF-8");
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The browser's end: no static key, the agent side's key and the psk from
|
|
776
|
+
* the link. Call `start` once the relay says `ready`.
|
|
777
|
+
*/
|
|
778
|
+
export function initiator(options: InitiatorOptions): RelaySession {
|
|
779
|
+
const handshake = HandshakeState.initiator({
|
|
780
|
+
prologue: prologue(options.link.room, options.link.webOrigin),
|
|
781
|
+
psk: options.link.psk,
|
|
782
|
+
remoteStaticPublicKey: options.link.staticPublicKey,
|
|
783
|
+
ephemeralPrivateKey: options.ephemeralPrivateKey,
|
|
784
|
+
});
|
|
785
|
+
return new RelaySession("initiator", "pairing", handshake, options);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The agent side's end, with its static key and the link's psk. It answers
|
|
790
|
+
* message 1 when it arrives.
|
|
791
|
+
*/
|
|
792
|
+
export function responder(options: ResponderOptions): RelaySession {
|
|
793
|
+
const handshake = HandshakeState.responder({
|
|
794
|
+
prologue: prologue(options.room, options.webOrigin),
|
|
795
|
+
psk: options.psk,
|
|
796
|
+
staticKeyPair: options.staticKeyPair,
|
|
797
|
+
ephemeralPrivateKey: options.ephemeralPrivateKey,
|
|
798
|
+
});
|
|
799
|
+
return new RelaySession("responder", "pairing", handshake, options);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* A remembered browser's end of a reconnect (Noise KK): its own static key,
|
|
804
|
+
* and the agent side's from the link it paired with. Call `start` once the
|
|
805
|
+
* relay says `ready`.
|
|
806
|
+
*/
|
|
807
|
+
export function reconnectInitiator(
|
|
808
|
+
options: ReconnectInitiatorOptions,
|
|
809
|
+
): RelaySession {
|
|
810
|
+
const handshake = KKHandshakeState.initiator({
|
|
811
|
+
prologue: prologue(options.room, options.webOrigin),
|
|
812
|
+
staticKey: options.staticKey,
|
|
813
|
+
remoteStaticPublicKey: options.remoteStaticPublicKey,
|
|
814
|
+
ephemeralPrivateKey: options.ephemeralPrivateKey,
|
|
815
|
+
});
|
|
816
|
+
return new RelaySession("initiator", "reconnect", handshake, options);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The agent side's end of a reconnect, with the key the browser sent in
|
|
821
|
+
* REMEMBER. It answers message 1 when it arrives, and opens by itself once
|
|
822
|
+
* the keys are confirmed.
|
|
823
|
+
*/
|
|
824
|
+
export function reconnectResponder(
|
|
825
|
+
options: ReconnectResponderOptions,
|
|
826
|
+
): RelaySession {
|
|
827
|
+
const handshake = KKHandshakeState.responder({
|
|
828
|
+
prologue: prologue(options.room, options.webOrigin),
|
|
829
|
+
staticKey: staticKey(options.staticKeyPair),
|
|
830
|
+
remoteStaticPublicKey: options.remoteStaticPublicKey,
|
|
831
|
+
ephemeralPrivateKey: options.ephemeralPrivateKey,
|
|
832
|
+
});
|
|
833
|
+
return new RelaySession("responder", "reconnect", handshake, options);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Remembers the ephemeral keys seen while a link is valid, to refuse replays of message 1. */
|
|
837
|
+
export class EphemeralCache {
|
|
838
|
+
private readonly seen: Uint8Array[] = [];
|
|
839
|
+
|
|
840
|
+
/** Returns false if `key` was seen before; otherwise remembers it. */
|
|
841
|
+
accept = (key: Uint8Array): boolean => {
|
|
842
|
+
if (this.seen.some((seen) => equalBytes(seen, key))) {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
this.seen.push(key.slice());
|
|
846
|
+
return true;
|
|
847
|
+
};
|
|
848
|
+
}
|