@peers-app/peers-sdk 0.25.5 → 0.25.7

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.
@@ -6,8 +6,19 @@ const data_1 = require("../data");
6
6
  const keys_1 = require("../keys");
7
7
  const peer_device_1 = require("../types/peer-device");
8
8
  const utils_1 = require("../utils");
9
+ const frame_cipher_1 = require("./frame-cipher");
9
10
  const socket_type_1 = require("./socket.type");
10
- /** Authenticated RPC connection between two Peers devices. */
11
+ /**
12
+ * Authenticated RPC connection between two Peers devices.
13
+ *
14
+ * The handshake (`doHandshake` on the client, `completeHandshake` on the
15
+ * server) authenticates both identities with signed, boxed messages. When both
16
+ * sockets support it, the handshake also agrees on per-connection frame-cipher
17
+ * keys (`frame-cipher.ts`) and arms the socket, after which every frame on the
18
+ * wire is sealed and the legacy per-message sign+box envelope is skipped.
19
+ * Against a peer that does not advertise `frameCrypto` the connection falls
20
+ * back to the legacy behaviour and logs a warning.
21
+ */
11
22
  class Connection {
12
23
  localDevice;
13
24
  localDeviceServerAddresses;
@@ -19,19 +30,40 @@ class Connection {
19
30
  publicKey: "",
20
31
  publicBoxKey: "",
21
32
  };
33
+ /**
34
+ * @deprecated Legacy transport classification, only consulted when the frame
35
+ * cipher is not armed. Removed once legacy peers are no longer supported.
36
+ */
22
37
  secureLocal = false;
38
+ /** @deprecated See {@link Connection.secureLocal}. */
23
39
  secureRemote = false;
40
+ /**
41
+ * Legacy per-message encryption override. Ignored once the frame cipher is
42
+ * armed. `"dont-encrypt-traffic-after-handshake"` is honoured only under
43
+ * `NODE_ENV=test`.
44
+ * @deprecated Removed with the legacy path.
45
+ */
24
46
  overrideEncryption;
47
+ /** Trust assigned to the remote identity once the handshake completes. */
25
48
  trustLevel = socket_type_1.TrustLevel.Unknown;
49
+ /** Server-side hook fired shortly after a successful handshake. */
26
50
  onHandshakeComplete;
51
+ /** RPC timeout for {@link Connection.emit}. */
27
52
  timeoutMs = 60_000; // 60 seconds
53
+ /** Preferred chunk size for callers streaming raw bytes over this connection. */
28
54
  maxChunkSize = socket_type_1.DEFAULT_MAX_CHUNK_SIZE;
55
+ /** Maximum accepted clock skew between the two devices' handshake timestamps. */
29
56
  handshakeTimestampToleranceMs = peer_device_1.PeerDeviceConsts.CLOCK_SKEW_TOLERANCE_MS;
30
57
  _connectionAddress;
58
+ /** Normalized server address this connection was established against. */
31
59
  get connectionAddress() {
32
60
  return this._connectionAddress;
33
61
  }
62
+ /** Underlying transport socket. */
34
63
  socket;
64
+ pendingClientHandshake;
65
+ /** Server side: keys to arm for sending right after the handshake response frame. */
66
+ pendingSendArm;
35
67
  constructor(socket, localDevice, localDeviceServerAddresses, getTrustLevel = () => Promise.resolve(socket_type_1.TrustLevel.Unknown)) {
36
68
  this.localDevice = localDevice;
37
69
  this.localDeviceServerAddresses = localDeviceServerAddresses;
@@ -42,6 +74,8 @@ class Connection {
42
74
  if (!socket.id) {
43
75
  socket.id = (0, utils_1.newid)();
44
76
  }
77
+ // Streams bypass RPC verification; hold them until the handshake decides.
78
+ socket.setPlaintextStreamsAllowed?.(false);
45
79
  // Only the server side of a connection should expose these methods
46
80
  this.exposeRPC("close", () => {
47
81
  (0, utils_1.sleep)(100).then(() => {
@@ -59,32 +93,40 @@ class Connection {
59
93
  return trustLevel;
60
94
  });
61
95
  this.exposeRPC("requestDeviceInfo", this.localDevice.getDeviceInfo.bind(this.localDevice));
62
- this.exposeRPC("completeHandshake", async (handshakeBox) => {
63
- const handshakeResponse = await this.completeHandshake(handshakeBox);
64
- return handshakeResponse;
65
- });
96
+ // Registered directly on the socket rather than through `on()`: the
97
+ // response must always be signed and boxed by the handshake itself (it
98
+ // carries the server's ephemeral key), and the reply callback needs the
99
+ // transport hint that arms the send cipher after this exact frame.
100
+ this.socket.on("completeHandshake", this.handleCompleteHandshakeRPC);
66
101
  this.exposeRPC("requestSecure", () => {
67
102
  this.secureRemote = true;
68
103
  return !this.encryptTraffic;
69
104
  });
70
105
  }
71
106
  }
107
+ /** True once the remote identity has been authenticated by the handshake. */
72
108
  get verified() {
73
109
  return this._verified;
74
110
  }
111
+ /** True when every frame on this connection is sealed by the frame cipher. */
112
+ get frameCipherArmed() {
113
+ return this.socket.frameCipherArmed === true;
114
+ }
75
115
  /**
76
- * Determines whether to apply application-level encryption (sign + box).
77
- *
78
- * Encryption is SKIPPED when:
79
- * - Binary transports (wrtc): Set secureLocal=true & secureRemote=true
80
- * because transport-level encryption (DTLS-SRTP, noise) handles security
81
- * - Secure WebSockets (wss/https): Both sides confirm secure protocol after handshake
116
+ * Whether to apply the legacy per-message envelope (sign + box) to RPC payloads.
82
117
  *
83
- * Encryption is APPLIED when:
84
- * - Insecure WebSockets (ws/http): Data travels over unencrypted channel
118
+ * Always false once the frame cipher is armed: the frame layer already gives
119
+ * confidentiality, integrity, and sender binding for every frame. Otherwise
120
+ * (legacy peer) the transport classification below applies:
121
+ * - skipped when both sides agreed the transport is secure (`secureLocal && secureRemote`)
122
+ * - applied on plain `ws` / `http`
85
123
  */
86
124
  get encryptTraffic() {
87
- if (this.overrideEncryption === "dont-encrypt-traffic-after-handshake") {
125
+ if (this.socket.frameCipherArmed) {
126
+ return false;
127
+ }
128
+ if (this.overrideEncryption === "dont-encrypt-traffic-after-handshake" &&
129
+ process.env.NODE_ENV === "test") {
88
130
  return false;
89
131
  }
90
132
  if (this.overrideEncryption === "encrypt-traffic-regardless-of-protocol") {
@@ -93,12 +135,34 @@ class Connection {
93
135
  // Encrypt only if EITHER side is using an insecure protocol
94
136
  return !(this.secureLocal && this.secureRemote);
95
137
  }
138
+ /** Copy of the authenticated remote device identity (empty before the handshake). */
96
139
  get remoteDeviceInfo() {
97
140
  return { ...this._remoteDeviceInfo };
98
141
  }
142
+ /** Socket ID shared by both ends; also the frame-cipher AAD and HKDF salt. */
99
143
  get connectionId() {
100
144
  return this.socket.id || "";
101
145
  }
146
+ get supportsFrameCrypto() {
147
+ return typeof this.socket.armFrameCipher === "function";
148
+ }
149
+ warnLegacyPeer(deviceId) {
150
+ console.warn(`[Connection] Peer ${deviceId} does not support frame encryption; using legacy per-message encryption`, { connectionId: this.connectionId, transport: this.socket.transportType ?? "unknown" });
151
+ }
152
+ handleCompleteHandshakeRPC = async (args, callback) => {
153
+ try {
154
+ const [handshakeBox] = args;
155
+ const response = await this.completeHandshake(handshakeBox);
156
+ const boxed = this.localDevice.signAndBoxDataForDevice(response, this._remoteDeviceInfo);
157
+ const armAfter = this.pendingSendArm;
158
+ this.pendingSendArm = undefined;
159
+ callback(null, boxed, armAfter ? { armFrameCipherAfter: armAfter } : undefined);
160
+ }
161
+ catch (e) {
162
+ console.error("Error handling completeHandshake", e);
163
+ callback({ error: e?.message ?? String(e), errorType: "RPC_ERROR" });
164
+ }
165
+ };
102
166
  /**
103
167
  * Get connection throughput stats with calculated rates.
104
168
  * Returns bytes/messages sent/received and both average and recent rates.
@@ -171,11 +235,12 @@ class Connection {
171
235
  return fn(...args);
172
236
  });
173
237
  }
238
+ /** Calls a remote RPC handler and resolves with its result (rejects on error or timeout). */
174
239
  emit(eventName, ...args) {
175
240
  return new Promise((resolve, reject) => {
176
241
  try {
177
- // Apply app-level encryption for insecure transports (ws/http)
178
- // Skipped for secure transports (wss/https, wrtc)
242
+ // Legacy per-message envelope; skipped when the frame cipher is armed
243
+ // or both sides agreed the transport is secure.
179
244
  if (this._verified && this.encryptTraffic) {
180
245
  args = this.localDevice.signAndBoxDataForDevice(args, this._remoteDeviceInfo);
181
246
  }
@@ -215,6 +280,11 @@ class Connection {
215
280
  }
216
281
  });
217
282
  }
283
+ /**
284
+ * Registers an RPC handler. Events other than the handshake set are rejected
285
+ * until the connection is verified; payloads use the legacy envelope only
286
+ * when {@link Connection.encryptTraffic} is true.
287
+ */
218
288
  on(eventName, handler) {
219
289
  const safeEvents = [
220
290
  "requestDeviceInfo",
@@ -230,13 +300,12 @@ class Connection {
230
300
  }
231
301
  let result;
232
302
  try {
233
- // Decrypt incoming args for insecure transports (ws/http)
234
- // Skipped for secure transports (wss/https, wrtc)
303
+ // Legacy per-message envelope; skipped when the frame cipher is armed
304
+ // or both sides agreed the transport is secure.
235
305
  if (this._verified && this.encryptTraffic) {
236
306
  args = this.localDevice.openBoxedAndSignedData(args);
237
307
  }
238
308
  result = await handler(...args);
239
- // Encrypt outgoing result for insecure transports
240
309
  if (this._verified && this.encryptTraffic) {
241
310
  result = this.localDevice.signAndBoxDataForDevice(result, this._remoteDeviceInfo);
242
311
  }
@@ -257,9 +326,15 @@ class Connection {
257
326
  }
258
327
  });
259
328
  }
329
+ /** Removes RPC handlers for `eventName`, or all handlers when omitted. */
260
330
  removeAllListeners(eventName) {
261
331
  this.socket.removeAllListeners(eventName);
262
332
  }
333
+ /**
334
+ * Clears the authenticated state so a new handshake can run. Does not disarm
335
+ * an already-armed frame cipher: the socket stays sealed with the current
336
+ * keys and a subsequent handshake replaces them.
337
+ */
263
338
  reset() {
264
339
  this._connectionAddress = undefined;
265
340
  this._remoteDeviceInfo = {
@@ -272,14 +347,46 @@ class Connection {
272
347
  this.secureLocal = false;
273
348
  this.secureRemote = false;
274
349
  this.trustLevel = socket_type_1.TrustLevel.Unknown;
350
+ this.pendingClientHandshake?.ephemeralSecretKey.fill(0);
351
+ this.pendingClientHandshake = undefined;
352
+ this.pendingSendArm = undefined;
353
+ this.socket.setPlaintextStreamsAllowed?.(false);
275
354
  }
355
+ /**
356
+ * Client side: builds the signed handshake (advertising frame-cipher support
357
+ * with a fresh ephemeral key when the socket can arm one) and boxes it to the
358
+ * remote device.
359
+ */
276
360
  initiateHandshake(serverAddress, remoteDeviceInfo) {
277
361
  if (remoteDeviceInfo) {
278
362
  this._remoteDeviceInfo = remoteDeviceInfo;
279
363
  }
280
- const handshake = this.localDevice.getHandshake(this.connectionId, serverAddress);
364
+ let frameCrypto;
365
+ this.pendingClientHandshake?.ephemeralSecretKey.fill(0);
366
+ this.pendingClientHandshake = undefined;
367
+ let ephemeralSecretKey;
368
+ if (this.supportsFrameCrypto) {
369
+ const ephemeral = (0, keys_1.newEphemeralBoxKeyPair)();
370
+ ephemeralSecretKey = ephemeral.secretKey;
371
+ frameCrypto = { version: 1, ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey) };
372
+ }
373
+ const handshake = this.localDevice.getHandshake(this.connectionId, serverAddress, frameCrypto);
374
+ if (ephemeralSecretKey) {
375
+ this.pendingClientHandshake = {
376
+ ephemeralSecretKey,
377
+ timestamp: handshake.contents.timestamp,
378
+ };
379
+ }
281
380
  return this.localDevice.boxDataForDevice(handshake, this._remoteDeviceInfo);
282
381
  }
382
+ /**
383
+ * Server side: verifies the client's handshake, resolves trust, and returns
384
+ * the (unsigned) response for the caller to sign and box. When the client
385
+ * offered `frameCrypto` and this socket can arm a cipher, derives the frame
386
+ * keys, arms the receive direction immediately, and schedules the send
387
+ * direction to arm right after the response frame (see
388
+ * {@link Connection.handleCompleteHandshakeRPC}).
389
+ */
283
390
  async completeHandshake(boxedHandshake) {
284
391
  const signedHandshake = this.localDevice.openBoxWithSecretKey(boxedHandshake);
285
392
  const _handshake = data_1.deviceHandshakeSchema.parse((0, keys_1.openSignedObject)(signedHandshake));
@@ -291,11 +398,27 @@ class Connection {
291
398
  const thisServerAddress = this.localDeviceServerAddresses?.find((a) => a === clientServerAddress) ||
292
399
  this.localDeviceServerAddresses?.[0] ||
293
400
  "unknown";
294
- // only set local secure if the server address matches the client's address and the address is using https
401
+ // Legacy classification: only meaningful when the frame cipher will not be armed.
295
402
  this.secureLocal =
296
403
  clientServerAddress === thisServerAddress && thisServerAddress.startsWith("https");
297
404
  this._connectionAddress = thisServerAddress;
298
- const handshakeResponse = this.localDevice.handshakeResponse(signedHandshake, this.connectionId, thisServerAddress);
405
+ let frameCrypto;
406
+ let frameKeys;
407
+ if (_handshake.frameCrypto && this.supportsFrameCrypto) {
408
+ const ephemeral = (0, keys_1.newEphemeralBoxKeyPair)();
409
+ frameCrypto = { version: 1, ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey) };
410
+ const derived = this.localDevice.deriveFrameKeys({
411
+ remotePublicBoxKey: _handshake.publicBoxKey,
412
+ ephemeralSecretKey: ephemeral.secretKey,
413
+ remoteEphemeralBoxKey: _handshake.frameCrypto.ephemeralBoxKey,
414
+ connectionId: this.connectionId,
415
+ clientDeviceId: _handshake.deviceId,
416
+ serverDeviceId: this.localDevice.deviceId,
417
+ timestamp: _handshake.timestamp,
418
+ });
419
+ frameKeys = (0, frame_cipher_1.frameKeysForRole)(derived, "server");
420
+ }
421
+ const handshakeResponse = this.localDevice.handshakeResponse(signedHandshake, this.connectionId, thisServerAddress, frameCrypto);
299
422
  this._remoteDeviceInfo = {
300
423
  userId: _handshake.userId,
301
424
  deviceId: _handshake.deviceId,
@@ -310,8 +433,17 @@ class Connection {
310
433
  this.emit("reset");
311
434
  throw new Error("Untrusted connection");
312
435
  }
313
- // console.log(`Connection ${this.connectionId} verified on server side with trust level ${this.trustLevel}`);
314
436
  this._verified = true;
437
+ if (frameKeys && this.socket.armFrameCipher) {
438
+ // The client seals everything after it reads our response, so receive
439
+ // can arm now; send arms after the response frame goes out.
440
+ this.socket.armFrameCipher(frameKeys, "receive");
441
+ this.pendingSendArm = frameKeys;
442
+ }
443
+ else {
444
+ this.warnLegacyPeer(_handshake.deviceId);
445
+ this.socket.setPlaintextStreamsAllowed?.(true);
446
+ }
315
447
  if (this.onHandshakeComplete) {
316
448
  setTimeout(() => {
317
449
  this.onHandshakeComplete?.(this._remoteDeviceInfo);
@@ -319,6 +451,11 @@ class Connection {
319
451
  }
320
452
  return handshakeResponse;
321
453
  }
454
+ /**
455
+ * Client side: runs the full handshake against `remoteAddress`. On success
456
+ * the remote identity is verified and, if both sockets support it, the
457
+ * frame cipher is armed in both directions before this resolves.
458
+ */
322
459
  async doHandshake(remoteAddress) {
323
460
  remoteAddress = normalizeAddress(remoteAddress);
324
461
  this.reset();
@@ -354,12 +491,33 @@ class Connection {
354
491
  this._remoteDeviceInfo = currentRemoteDeviceInfo;
355
492
  this._verified = true;
356
493
  this._connectionAddress = remoteAddress;
494
+ const pending = this.pendingClientHandshake;
495
+ this.pendingClientHandshake = undefined;
496
+ if (pending && handshakeResponse.frameCrypto && this.socket.armFrameCipher) {
497
+ const derived = this.localDevice.deriveFrameKeys({
498
+ remotePublicBoxKey: handshakeResponse.publicBoxKey,
499
+ ephemeralSecretKey: pending.ephemeralSecretKey,
500
+ remoteEphemeralBoxKey: handshakeResponse.frameCrypto.ephemeralBoxKey,
501
+ connectionId: this.connectionId,
502
+ clientDeviceId: this.localDevice.deviceId,
503
+ serverDeviceId: handshakeResponse.deviceId,
504
+ timestamp: pending.timestamp,
505
+ });
506
+ // The server armed receive before replying and send right after; from
507
+ // here on both directions are sealed.
508
+ this.socket.armFrameCipher((0, frame_cipher_1.frameKeysForRole)(derived, "client"));
509
+ }
510
+ else {
511
+ pending?.ephemeralSecretKey.fill(0);
512
+ this.warnLegacyPeer(handshakeResponse.deviceId);
513
+ this.socket.setPlaintextStreamsAllowed?.(true);
514
+ }
515
+ // Legacy classification, only consulted when the frame cipher is not armed.
357
516
  const secureProtocols = ["https", "wss", "wrtc"];
358
- // const secureProtocols = ['https', 'wss',]
359
517
  this.secureLocal =
360
518
  secureProtocols.some((s) => remoteAddress.startsWith(s)) &&
361
519
  handshakeResponse.serverAddress === remoteAddress;
362
- if (this.secureLocal) {
520
+ if (this.secureLocal && !this.socket.frameCipherArmed) {
363
521
  await this.emit("requestSecure");
364
522
  }
365
523
  this.trustLevel = await this.getTrustLevel(currentRemoteDeviceInfo, true);
@@ -368,7 +526,6 @@ class Connection {
368
526
  this.emit("reset");
369
527
  throw new Error("Untrusted connection");
370
528
  }
371
- // console.log(`Connection ${this.connectionId} (${remoteAddress}) verified on client side with trust level ${this.trustLevel}`);
372
529
  return handshakeResponse;
373
530
  }
374
531
  closeLocal() {
@@ -376,6 +533,7 @@ class Connection {
376
533
  this.removeAllListeners();
377
534
  this.socket.disconnect(true);
378
535
  }
536
+ /** Round-trip time in milliseconds. @throws if the connection is not verified */
379
537
  async ping() {
380
538
  if (!this._verified) {
381
539
  throw new Error("Cannot ping unverified connection");
@@ -385,6 +543,7 @@ class Connection {
385
543
  return Date.now() - startTime;
386
544
  }
387
545
  closed = false;
546
+ /** Notifies the peer (best effort, 100 ms) then tears down the local side. */
388
547
  async close() {
389
548
  if (this.closed)
390
549
  return;
@@ -1,9 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const nacl = require("tweetnacl");
3
4
  const keys_1 = require("../keys");
4
5
  const utils_1 = require("../utils");
6
+ const binary_peer_connection_v2_1 = require("./binary-peer-connection-v2");
5
7
  const connection_1 = require("./connection");
6
8
  const device_1 = require("./device");
9
+ const frame_cipher_1 = require("./frame-cipher");
7
10
  function createTestSocket(localHandlers, remoteHandlers) {
8
11
  const socket = {
9
12
  id: (0, utils_1.newid)(),
@@ -357,4 +360,225 @@ describe(connection_1.Connection, () => {
357
360
  expect(serverConnection.verified).toBe(false);
358
361
  });
359
362
  });
363
+ // ─── Frame cipher ──────────────────────────────────────────────────────────
364
+ //
365
+ // These run over real binary peer sockets (the production framing) so the
366
+ // handshake actually arms the cipher and the wire can be inspected.
367
+ describe("frame cipher", () => {
368
+ class TapPeer {
369
+ other;
370
+ sent = [];
371
+ destroyed = false;
372
+ dataHandlers = [];
373
+ closeHandlers = [];
374
+ on(event, handler) {
375
+ if (event === "data")
376
+ this.dataHandlers.push(handler);
377
+ if (event === "close")
378
+ this.closeHandlers.push(handler);
379
+ }
380
+ send(data) {
381
+ const bytes = Buffer.from(data);
382
+ this.sent.push(bytes);
383
+ const target = this.other;
384
+ if (target) {
385
+ setImmediate(() => {
386
+ for (const h of target.dataHandlers)
387
+ h(bytes);
388
+ });
389
+ }
390
+ }
391
+ destroy() {
392
+ this.destroyed = true;
393
+ for (const h of this.closeHandlers)
394
+ h();
395
+ }
396
+ }
397
+ function pair(protocol, opts = {}) {
398
+ const clientPeer = new TapPeer();
399
+ const serverPeer = new TapPeer();
400
+ clientPeer.other = serverPeer;
401
+ serverPeer.other = clientPeer;
402
+ const connectionId = (0, utils_1.newid)();
403
+ const userId = (0, utils_1.newid)();
404
+ const clientDevice = new device_1.Device(userId, (0, utils_1.newid)(), (0, keys_1.newKeys)());
405
+ const serverDevice = new device_1.Device(userId, (0, utils_1.newid)(), (0, keys_1.newKeys)());
406
+ const client = (0, binary_peer_connection_v2_1.wrapBinaryPeer)(connectionId, clientPeer, clientDevice, true, { protocol });
407
+ const server = (0, binary_peer_connection_v2_1.wrapBinaryPeer)(connectionId, serverPeer, serverDevice, false, { protocol });
408
+ // A "legacy" peer is one whose socket cannot arm a cipher, so it never advertises frameCrypto.
409
+ if (opts.legacyClient)
410
+ delete client.socket.armFrameCipher;
411
+ if (opts.legacyServer)
412
+ delete server.socket.armFrameCipher;
413
+ return {
414
+ clientPeer,
415
+ serverPeer,
416
+ client,
417
+ server,
418
+ clientDevice,
419
+ serverDevice,
420
+ address: `${protocol}://${connectionId}`,
421
+ };
422
+ }
423
+ function wire(...peers) {
424
+ return peers.flatMap((p) => p.sent);
425
+ }
426
+ for (const protocol of ["ws", "wrtc"]) {
427
+ it(`arms both sides after the handshake on ${protocol} and stops boxing RPC`, async () => {
428
+ const { client, server, clientDevice, serverDevice, clientPeer, serverPeer, address } = pair(protocol);
429
+ const clientBox = jest.spyOn(clientDevice, "signAndBoxDataForDevice");
430
+ const serverBox = jest.spyOn(serverDevice, "signAndBoxDataForDevice");
431
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
432
+ try {
433
+ await client.doHandshake(address);
434
+ expect(client.frameCipherArmed).toBe(true);
435
+ expect(server.frameCipherArmed).toBe(true);
436
+ expect(client.verified).toBe(true);
437
+ expect(server.verified).toBe(true);
438
+ expect(client.encryptTraffic).toBe(false);
439
+ expect(server.encryptTraffic).toBe(false);
440
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("does not support frame encryption"), expect.anything());
441
+ // The only box+sign so far is the server's handshake response.
442
+ expect(serverBox).toHaveBeenCalledTimes(1);
443
+ expect(clientBox).toHaveBeenCalledTimes(0);
444
+ // Application RPC: no envelope, but sealed on the wire.
445
+ const marker = `marker-${(0, utils_1.newid)()}`;
446
+ server.exposeRPC("echo", async (s) => `${s}!`);
447
+ const clientBefore = clientPeer.sent.length;
448
+ const serverBefore = serverPeer.sent.length;
449
+ expect(await client.emit("echo", marker)).toBe(`${marker}!`);
450
+ expect(clientBox).toHaveBeenCalledTimes(0);
451
+ expect(serverBox).toHaveBeenCalledTimes(1);
452
+ const rpcFrames = [
453
+ ...clientPeer.sent.slice(clientBefore),
454
+ ...serverPeer.sent.slice(serverBefore),
455
+ ];
456
+ expect(rpcFrames.length).toBe(2);
457
+ for (const frame of rpcFrames) {
458
+ expect(frame[0]).toBe(frame_cipher_1.FRAME_ENC);
459
+ expect(frame.indexOf(Buffer.from(marker))).toBe(-1);
460
+ }
461
+ // Streams: delivered and sealed.
462
+ const payload = nacl.randomBytes(48);
463
+ const received = new Promise((resolve) => server.socket.onRawBytes?.("s", (d) => resolve(d)));
464
+ await client.socket.sendRawBytes?.("s", payload);
465
+ expect(Buffer.from(await received)).toEqual(Buffer.from(payload));
466
+ const last = clientPeer.sent[clientPeer.sent.length - 1];
467
+ expect(last[0]).toBe(frame_cipher_1.FRAME_ENC);
468
+ expect(last.indexOf(Buffer.from(payload))).toBe(-1);
469
+ }
470
+ finally {
471
+ warn.mockRestore();
472
+ clientPeer.destroy();
473
+ serverPeer.destroy();
474
+ }
475
+ });
476
+ }
477
+ it("no longer double-encrypts over wrtc (finding 2)", async () => {
478
+ const { client, server, clientPeer, serverPeer, address } = pair("wrtc");
479
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
480
+ try {
481
+ await client.doHandshake(address);
482
+ // Before: client {secureLocal: true, secureRemote: false}, server the
483
+ // reverse, encryptTraffic true on both. Now the cipher decides.
484
+ expect(client.encryptTraffic).toBe(false);
485
+ expect(server.encryptTraffic).toBe(false);
486
+ expect(client.frameCipherArmed).toBe(true);
487
+ // requestSecure is never sent when the cipher is armed.
488
+ const plaintextFrames = wire(clientPeer, serverPeer).filter((f) => f[0] !== frame_cipher_1.FRAME_ENC);
489
+ for (const frame of plaintextFrames) {
490
+ expect(frame.indexOf(Buffer.from("requestSecure"))).toBe(-1);
491
+ }
492
+ }
493
+ finally {
494
+ warn.mockRestore();
495
+ clientPeer.destroy();
496
+ serverPeer.destroy();
497
+ }
498
+ });
499
+ it("only the handshake frames are plaintext, and the ephemeral keys are never in the clear", async () => {
500
+ const { client, clientPeer, serverPeer, address } = pair("ws");
501
+ await client.doHandshake(address);
502
+ const frames = wire(clientPeer, serverPeer);
503
+ // reset, requestDeviceInfo, completeHandshake: 3 requests + 3 replies in the clear.
504
+ const plaintext = frames.filter((f) => f[0] !== frame_cipher_1.FRAME_ENC);
505
+ expect(plaintext.length).toBe(6);
506
+ for (const frame of plaintext) {
507
+ expect(frame.indexOf(Buffer.from("ephemeralBoxKey"))).toBe(-1);
508
+ }
509
+ clientPeer.destroy();
510
+ serverPeer.destroy();
511
+ });
512
+ it("drops plaintext stream frames before the handshake completes", async () => {
513
+ const { client, server, clientPeer, serverPeer, address } = pair("ws");
514
+ let delivered = 0;
515
+ server.socket.onRawBytes?.("early", () => delivered++);
516
+ await client.socket.sendRawBytes?.("early", new Uint8Array([1, 2, 3]));
517
+ await (0, utils_1.sleep)(20);
518
+ expect(delivered).toBe(0);
519
+ expect(serverPeer.destroyed).toBe(false);
520
+ await client.doHandshake(address);
521
+ await client.socket.sendRawBytes?.("early", new Uint8Array([4]));
522
+ await (0, utils_1.sleep)(20);
523
+ expect(delivered).toBe(1);
524
+ clientPeer.destroy();
525
+ serverPeer.destroy();
526
+ });
527
+ for (const [label, opts] of [
528
+ ["legacy client", { legacyClient: true }],
529
+ ["legacy server", { legacyServer: true }],
530
+ ]) {
531
+ it(`falls back to the legacy envelope and warns against a ${label}`, async () => {
532
+ const { client, server, clientDevice, clientPeer, serverPeer, address } = pair("ws", opts);
533
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
534
+ const clientBox = jest.spyOn(clientDevice, "signAndBoxDataForDevice");
535
+ try {
536
+ await client.doHandshake(address);
537
+ expect(client.verified).toBe(true);
538
+ expect(server.verified).toBe(true);
539
+ expect(client.frameCipherArmed).toBe(false);
540
+ expect(server.frameCipherArmed).toBe(false);
541
+ // ws://: legacy classification says encrypt per message.
542
+ expect(client.encryptTraffic).toBe(true);
543
+ expect(server.encryptTraffic).toBe(true);
544
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("does not support frame encryption"), expect.objectContaining({ transport: "ws" }));
545
+ server.exposeRPC("echo", async (s) => `${s}!`);
546
+ expect(await client.emit("echo", "hi")).toBe("hi!");
547
+ expect(clientBox).toHaveBeenCalled();
548
+ for (const frame of wire(clientPeer, serverPeer)) {
549
+ expect(frame[0]).not.toBe(frame_cipher_1.FRAME_ENC);
550
+ }
551
+ // Plaintext streams are delivered once the legacy handshake verified the peer.
552
+ let delivered = 0;
553
+ server.socket.onRawBytes?.("legacy", () => delivered++);
554
+ await client.socket.sendRawBytes?.("legacy", new Uint8Array([1]));
555
+ await (0, utils_1.sleep)(20);
556
+ expect(delivered).toBe(1);
557
+ }
558
+ finally {
559
+ warn.mockRestore();
560
+ clientPeer.destroy();
561
+ serverPeer.destroy();
562
+ }
563
+ });
564
+ }
565
+ it("ignores dont-encrypt-traffic-after-handshake outside NODE_ENV=test", async () => {
566
+ const { client, clientPeer, serverPeer, address } = pair("ws", { legacyServer: true });
567
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
568
+ const previous = process.env.NODE_ENV;
569
+ try {
570
+ await client.doHandshake(address);
571
+ client.overrideEncryption = "dont-encrypt-traffic-after-handshake";
572
+ expect(client.encryptTraffic).toBe(false);
573
+ process.env.NODE_ENV = "production";
574
+ expect(client.encryptTraffic).toBe(true);
575
+ }
576
+ finally {
577
+ process.env.NODE_ENV = previous;
578
+ warn.mockRestore();
579
+ clientPeer.destroy();
580
+ serverPeer.destroy();
581
+ }
582
+ });
583
+ });
360
584
  });