@peers-app/peers-sdk 0.27.4 → 0.27.5

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.
@@ -14,12 +14,10 @@ const socket_type_1 = require("./socket.type");
14
14
  * Authenticated RPC connection between two Peers devices.
15
15
  *
16
16
  * The handshake (`doHandshake` on the client, `completeHandshake` on the
17
- * server) authenticates both identities with signed, boxed messages. When both
18
- * sockets support it, the handshake also agrees on per-connection frame-cipher
19
- * keys (`frame-cipher.ts`) and arms the socket, after which every frame on the
20
- * wire is sealed and the legacy per-message sign+box envelope is skipped.
21
- * Against a peer that does not advertise `frameCrypto` the connection falls
22
- * back to the legacy behaviour and logs a warning.
17
+ * server) authenticates both identities with signed, boxed messages and agrees
18
+ * on per-connection frame-cipher keys (`frame-cipher.ts`), then arms the
19
+ * socket: every frame after the handshake is sealed. A peer that does not
20
+ * offer frame encryption (a build before 0.25.7) is refused.
23
21
  */
24
22
  class Connection {
25
23
  localDevice;
@@ -32,23 +30,9 @@ class Connection {
32
30
  publicKey: "",
33
31
  publicBoxKey: "",
34
32
  };
35
- /**
36
- * @deprecated Legacy transport classification, only consulted when the frame
37
- * cipher is not armed. Removed once legacy peers are no longer supported.
38
- */
39
- secureLocal = false;
40
- /** @deprecated See {@link Connection.secureLocal}. */
41
- secureRemote = false;
42
- /**
43
- * Legacy per-message encryption override. Ignored once the frame cipher is
44
- * armed. `"dont-encrypt-traffic-after-handshake"` is honoured only under
45
- * `NODE_ENV=test`.
46
- * @deprecated Removed with the legacy path.
47
- */
48
- overrideEncryption;
49
33
  /** Trust assigned to the remote identity once the handshake completes. */
50
34
  trustLevel = socket_type_1.TrustLevel.Unknown;
51
- /** Server-side hook fired shortly after a successful handshake. */
35
+ /** Server-side hook fired shortly after the successful handshake reply has been sent. */
52
36
  onHandshakeComplete;
53
37
  /** RPC timeout for {@link Connection.emit}. */
54
38
  timeoutMs = 60_000; // 60 seconds
@@ -66,6 +50,8 @@ class Connection {
66
50
  pendingClientHandshake;
67
51
  /** Server side: keys to arm for sending right after the handshake response frame. */
68
52
  pendingSendArm;
53
+ /** Bumped by every {@link Connection.reset}, so a handshake can tell it was cut short. */
54
+ handshakeGeneration = 0;
69
55
  constructor(socket, localDevice, localDeviceServerAddresses, getTrustLevel = () => Promise.resolve(socket_type_1.TrustLevel.Unknown)) {
70
56
  this.localDevice = localDevice;
71
57
  this.localDeviceServerAddresses = localDeviceServerAddresses;
@@ -76,7 +62,7 @@ class Connection {
76
62
  if (!socket.id) {
77
63
  socket.id = (0, utils_1.newid)();
78
64
  }
79
- // Streams bypass RPC verification; hold them until the handshake decides.
65
+ // Stream frames bypass RPC verification; only sealed ones may reach a Connection.
80
66
  socket.setPlaintextStreamsAllowed?.(false);
81
67
  // Only the server side of a connection should expose these methods
82
68
  this.exposeRPC("close", () => {
@@ -100,10 +86,6 @@ class Connection {
100
86
  // carries the server's ephemeral key), and the reply callback needs the
101
87
  // transport hint that arms the send cipher after this exact frame.
102
88
  this.socket.on("completeHandshake", this.handleCompleteHandshakeRPC);
103
- this.exposeRPC("requestSecure", () => {
104
- this.secureRemote = true;
105
- return !this.encryptTraffic;
106
- });
107
89
  }
108
90
  }
109
91
  /** True once the remote identity has been authenticated by the handshake. */
@@ -114,29 +96,6 @@ class Connection {
114
96
  get frameCipherArmed() {
115
97
  return this.socket.frameCipherArmed === true;
116
98
  }
117
- /**
118
- * Whether to apply the legacy per-message envelope (sign + box) to RPC payloads.
119
- *
120
- * Always false once the frame cipher is armed: the frame layer already gives
121
- * confidentiality, integrity, and sender binding for every frame. Otherwise
122
- * (legacy peer) the transport classification below applies:
123
- * - skipped when both sides agreed the transport is secure (`secureLocal && secureRemote`)
124
- * - applied on plain `ws` / `http`
125
- */
126
- get encryptTraffic() {
127
- if (this.socket.frameCipherArmed) {
128
- return false;
129
- }
130
- if (this.overrideEncryption === "dont-encrypt-traffic-after-handshake" &&
131
- process.env.NODE_ENV === "test") {
132
- return false;
133
- }
134
- if (this.overrideEncryption === "encrypt-traffic-regardless-of-protocol") {
135
- return true;
136
- }
137
- // Encrypt only if EITHER side is using an insecure protocol
138
- return !(this.secureLocal && this.secureRemote);
139
- }
140
99
  /** Copy of the authenticated remote device identity (empty before the handshake). */
141
100
  get remoteDeviceInfo() {
142
101
  return { ...this._remoteDeviceInfo };
@@ -145,20 +104,33 @@ class Connection {
145
104
  get connectionId() {
146
105
  return this.socket.id || "";
147
106
  }
148
- get supportsFrameCrypto() {
149
- return typeof this.socket.armFrameCipher === "function";
150
- }
151
- warnLegacyPeer(deviceId) {
152
- console.warn(`[Connection] Peer ${deviceId} does not support frame encryption; using legacy per-message encryption`, { connectionId: this.connectionId, transport: this.socket.transportType ?? "unknown" });
107
+ /** The socket's frame-cipher arming; throws when the transport can't encrypt frames. */
108
+ requireFrameCipher() {
109
+ const arm = this.socket.armFrameCipher;
110
+ if (typeof arm !== "function") {
111
+ throw new Error("This transport can't encrypt frames, so it can't carry a connection");
112
+ }
113
+ return arm.bind(this.socket);
153
114
  }
154
115
  handleCompleteHandshakeRPC = async (args, callback) => {
155
116
  try {
117
+ const generation = this.handshakeGeneration;
156
118
  const [handshakeBox] = args;
157
119
  const response = await this.completeHandshake(handshakeBox);
120
+ this.assertHandshakeCurrent(generation);
158
121
  const boxed = this.localDevice.signAndBoxDataForDevice(response, this._remoteDeviceInfo);
159
122
  const armAfter = this.pendingSendArm;
160
123
  this.pendingSendArm = undefined;
161
- callback(null, boxed, armAfter ? { armFrameCipherAfter: armAfter } : undefined);
124
+ await callback(null, boxed, armAfter ? { armFrameCipherAfter: armAfter } : undefined);
125
+ // The reply may wait on backpressure or span several frames. Start application RPCs
126
+ // only once the final frame has gone out and the send cipher has armed.
127
+ if (this.onHandshakeComplete) {
128
+ setTimeout(() => {
129
+ if (generation === this.handshakeGeneration && this.socket.connected !== false) {
130
+ this.onHandshakeComplete?.(this._remoteDeviceInfo);
131
+ }
132
+ }, 10);
133
+ }
162
134
  }
163
135
  catch (e) {
164
136
  console.error("Error handling completeHandshake", e);
@@ -239,13 +211,16 @@ class Connection {
239
211
  }
240
212
  /** Calls a remote RPC handler and resolves with its result (rejects on error or timeout). */
241
213
  emit(eventName, ...args) {
214
+ return this.request(eventName, args);
215
+ }
216
+ /**
217
+ * Sends one RPC. `onReply`, when given, runs synchronously while the reply frame is
218
+ * processed, before any later frame from the peer; its return value (or throw) settles
219
+ * the call.
220
+ */
221
+ request(eventName, args, onReply) {
242
222
  return new Promise((resolve, reject) => {
243
223
  try {
244
- // Legacy per-message envelope; skipped when the frame cipher is armed
245
- // or both sides agreed the transport is secure.
246
- if (this._verified && this.encryptTraffic) {
247
- args = this.localDevice.signAndBoxDataForDevice(args, this._remoteDeviceInfo);
248
- }
249
224
  const timeoutMs = this.timeoutMs;
250
225
  const timeoutPid = setTimeout(() => {
251
226
  reject(`${eventName} timed out after ${timeoutMs}ms`);
@@ -254,16 +229,17 @@ class Connection {
254
229
  try {
255
230
  clearTimeout(timeoutPid);
256
231
  if (err) {
257
- err = this.localDevice.unwrapResponse(err);
258
232
  return reject(err);
259
233
  }
260
- if (eventName === "requestSecure") {
261
- this.secureRemote = this.localDevice.unwrapResponse(result);
262
- result = this.secureRemote;
263
- }
264
- else if (this._verified && this.encryptTraffic) {
265
- // this purposely results in an error if the client is not securing the data when it should be
266
- result = this.localDevice.openBoxedAndSignedData(result);
234
+ if (onReply) {
235
+ let value;
236
+ try {
237
+ value = onReply(result);
238
+ }
239
+ catch (e) {
240
+ return reject(e);
241
+ }
242
+ return resolve(value);
267
243
  }
268
244
  resolve(result);
269
245
  }
@@ -284,8 +260,7 @@ class Connection {
284
260
  }
285
261
  /**
286
262
  * Registers an RPC handler. Events other than the handshake set are rejected
287
- * until the connection is verified; payloads use the legacy envelope only
288
- * when {@link Connection.encryptTraffic} is true.
263
+ * until the connection is verified.
289
264
  */
290
265
  on(eventName, handler) {
291
266
  const safeEvents = [
@@ -300,31 +275,12 @@ class Connection {
300
275
  console.error(`Ignoring event from unverified connection: ${eventName}`);
301
276
  return callback(`Connection not verified. Handshake must be completed successfully before calling ${eventName}`);
302
277
  }
303
- let result;
304
278
  try {
305
- // Legacy per-message envelope; skipped when the frame cipher is armed
306
- // or both sides agreed the transport is secure.
307
- if (this._verified && this.encryptTraffic) {
308
- args = this.localDevice.openBoxedAndSignedData(args);
309
- }
310
- result = await handler(...args);
311
- if (this._verified && this.encryptTraffic) {
312
- result = this.localDevice.signAndBoxDataForDevice(result, this._remoteDeviceInfo);
313
- }
314
- callback(null, result);
279
+ callback(null, await handler(...args));
315
280
  }
316
281
  catch (e) {
317
282
  console.error(`Error handling ${eventName}(${JSON.stringify(args)})`, e);
318
- let rpcError = { error: e.message, errorType: "RPC_ERROR" };
319
- if (this._verified && this.encryptTraffic) {
320
- try {
321
- rpcError = this.localDevice.signAndBoxDataForDevice(rpcError, this._remoteDeviceInfo);
322
- }
323
- catch (e) {
324
- console.error("Error signing and boxing RPC error", e);
325
- }
326
- }
327
- callback(rpcError);
283
+ callback({ error: e.message, errorType: "RPC_ERROR" });
328
284
  }
329
285
  });
330
286
  }
@@ -346,50 +302,54 @@ class Connection {
346
302
  publicBoxKey: "",
347
303
  };
348
304
  this._verified = false;
349
- this.secureLocal = false;
350
- this.secureRemote = false;
351
305
  this.trustLevel = socket_type_1.TrustLevel.Unknown;
352
306
  this.pendingClientHandshake?.ephemeralSecretKey.fill(0);
353
307
  this.pendingClientHandshake = undefined;
354
308
  this.pendingSendArm = undefined;
355
- this.socket.setPlaintextStreamsAllowed?.(false);
309
+ this.handshakeGeneration++;
356
310
  }
357
311
  /**
358
- * Client side: builds the signed handshake (advertising frame-cipher support
359
- * with a fresh ephemeral key when the socket can arm one) and boxes it to the
360
- * remote device.
312
+ * Client side: builds the signed handshake (offering frame encryption with a
313
+ * fresh ephemeral key) and boxes it to the remote device.
361
314
  */
362
315
  initiateHandshake(serverAddress, remoteDeviceInfo) {
363
316
  if (remoteDeviceInfo) {
364
317
  this._remoteDeviceInfo = remoteDeviceInfo;
365
318
  }
366
- let frameCrypto;
367
319
  this.pendingClientHandshake?.ephemeralSecretKey.fill(0);
368
320
  this.pendingClientHandshake = undefined;
369
- let ephemeralSecretKey;
370
- if (this.supportsFrameCrypto) {
371
- const ephemeral = (0, keys_1.newEphemeralBoxKeyPair)();
372
- ephemeralSecretKey = ephemeral.secretKey;
373
- frameCrypto = { version: 1, ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey) };
374
- }
375
- const handshake = this.localDevice.getHandshake(this.connectionId, serverAddress, frameCrypto, (0, key_change_1.peekLocalKeyContest)());
376
- if (ephemeralSecretKey) {
321
+ this.requireFrameCipher();
322
+ const ephemeral = (0, keys_1.newEphemeralBoxKeyPair)();
323
+ const ephemeralSecretKey = ephemeral.secretKey;
324
+ const frameCrypto = {
325
+ version: 1,
326
+ ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey),
327
+ };
328
+ try {
329
+ const handshake = this.localDevice.getHandshake(this.connectionId, serverAddress, frameCrypto, (0, key_change_1.peekLocalKeyContest)());
377
330
  this.pendingClientHandshake = {
378
331
  ephemeralSecretKey,
379
332
  timestamp: handshake.contents.timestamp,
380
333
  };
334
+ return this.localDevice.boxDataForDevice(handshake, this._remoteDeviceInfo);
335
+ }
336
+ catch (e) {
337
+ ephemeralSecretKey.fill(0);
338
+ this.pendingClientHandshake = undefined;
339
+ throw e;
381
340
  }
382
- return this.localDevice.boxDataForDevice(handshake, this._remoteDeviceInfo);
383
341
  }
384
342
  /**
385
343
  * Server side: verifies the client's handshake, resolves trust, and returns
386
- * the (unsigned) response for the caller to sign and box. When the client
387
- * offered `frameCrypto` and this socket can arm a cipher, derives the frame
344
+ * the (unsigned) response for the caller to sign and box. Derives the frame
388
345
  * keys, arms the receive direction immediately, and schedules the send
389
346
  * direction to arm right after the response frame (see
390
- * {@link Connection.handleCompleteHandshakeRPC}).
347
+ * {@link Connection.handleCompleteHandshakeRPC}). A client that does not
348
+ * offer frame encryption is refused.
391
349
  */
392
350
  async completeHandshake(boxedHandshake) {
351
+ const generation = this.handshakeGeneration;
352
+ this.assertHandshakeCurrent(generation);
393
353
  const signedHandshake = this.localDevice.openBoxWithSecretKey(boxedHandshake);
394
354
  const _handshake = data_1.deviceHandshakeSchema.parse((0, keys_1.openSignedObject)(signedHandshake));
395
355
  const timeDiff = Math.abs(Date.now() - _handshake.timestamp);
@@ -400,15 +360,17 @@ class Connection {
400
360
  const thisServerAddress = this.localDeviceServerAddresses?.find((a) => a === clientServerAddress) ||
401
361
  this.localDeviceServerAddresses?.[0] ||
402
362
  "unknown";
403
- // Legacy classification: only meaningful when the frame cipher will not be armed.
404
- this.secureLocal =
405
- clientServerAddress === thisServerAddress && thisServerAddress.startsWith("https");
406
- this._connectionAddress = thisServerAddress;
407
- let frameCrypto;
363
+ if (!_handshake.frameCrypto) {
364
+ throw legacyPeerError(_handshake.deviceId);
365
+ }
366
+ const armFrameCipher = this.requireFrameCipher();
367
+ const ephemeral = (0, keys_1.newEphemeralBoxKeyPair)();
368
+ const frameCrypto = {
369
+ version: 1,
370
+ ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey),
371
+ };
408
372
  let frameKeys;
409
- if (_handshake.frameCrypto && this.supportsFrameCrypto) {
410
- const ephemeral = (0, keys_1.newEphemeralBoxKeyPair)();
411
- frameCrypto = { version: 1, ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey) };
373
+ try {
412
374
  const derived = this.localDevice.deriveFrameKeys({
413
375
  remotePublicBoxKey: _handshake.publicBoxKey,
414
376
  ephemeralSecretKey: ephemeral.secretKey,
@@ -420,8 +382,26 @@ class Connection {
420
382
  });
421
383
  frameKeys = (0, frame_cipher_1.frameKeysForRole)(derived, "server");
422
384
  }
385
+ finally {
386
+ // Invalid peer keys can throw before Device.deriveFrameKeys reaches its cleanup.
387
+ ephemeral.secretKey.fill(0);
388
+ }
423
389
  const handshakeResponse = this.localDevice.handshakeResponse(signedHandshake, this.connectionId, thisServerAddress, frameCrypto, (0, key_change_1.peekLocalKeyContest)());
424
390
  await noteHandshakeContest(_handshake.keyContest);
391
+ this.assertHandshakeCurrent(generation);
392
+ const trustLevel = await this.getTrustLevel(_handshake, true);
393
+ this.assertHandshakeCurrent(generation);
394
+ if (trustLevel < socket_type_1.TrustLevel.Unknown) {
395
+ this.resetRemote();
396
+ throw new Error("Untrusted connection");
397
+ }
398
+ // The client seals everything after it reads our response, so receive
399
+ // can arm now; send arms after the response frame goes out.
400
+ armFrameCipher(frameKeys, "receive");
401
+ this.pendingSendArm = frameKeys;
402
+ // Publish the identity only after arming succeeds; a failed cipher must not
403
+ // leave application RPC handlers accepting an unsealed connection.
404
+ this._connectionAddress = thisServerAddress;
425
405
  this._remoteDeviceInfo = {
426
406
  userId: _handshake.userId,
427
407
  deviceId: _handshake.deviceId,
@@ -430,57 +410,76 @@ class Connection {
430
410
  userName: _handshake.userName,
431
411
  deviceName: _handshake.deviceName,
432
412
  };
433
- this.trustLevel = await this.getTrustLevel(_handshake, true);
434
- if (this.trustLevel < socket_type_1.TrustLevel.Unknown) {
435
- this.resetRemote();
436
- throw new Error("Untrusted connection");
437
- }
413
+ this.trustLevel = trustLevel;
438
414
  this._verified = true;
439
- if (frameKeys && this.socket.armFrameCipher) {
440
- // The client seals everything after it reads our response, so receive
441
- // can arm now; send arms after the response frame goes out.
442
- this.socket.armFrameCipher(frameKeys, "receive");
443
- this.pendingSendArm = frameKeys;
444
- }
445
- else {
446
- this.warnLegacyPeer(_handshake.deviceId);
447
- this.socket.setPlaintextStreamsAllowed?.(true);
448
- }
449
- if (this.onHandshakeComplete) {
450
- setTimeout(() => {
451
- this.onHandshakeComplete?.(this._remoteDeviceInfo);
452
- }, 10);
453
- }
454
415
  return handshakeResponse;
455
416
  }
456
417
  /**
457
418
  * Client side: runs the full handshake against `remoteAddress`. On success
458
- * the remote identity is verified and, if both sockets support it, the
459
- * frame cipher is armed in both directions before this resolves.
419
+ * the remote identity is verified and the frame cipher is armed in both
420
+ * directions before this resolves.
460
421
  */
461
422
  async doHandshake(remoteAddress) {
462
423
  remoteAddress = normalizeAddress(remoteAddress);
463
424
  this.reset();
464
- await this.emit("reset");
465
- const remoteDeviceInfoSigned = await this.emit("requestDeviceInfo");
466
- const remoteDeviceInfo = data_1.deviceInfoSchema.parse((0, keys_1.openSignedObject)(remoteDeviceInfoSigned));
467
- if (remoteDeviceInfoSigned.publicKey !== remoteDeviceInfo.publicKey) {
468
- throw new Error("Device info signing key does not match claimed identity key");
425
+ const generation = this.handshakeGeneration;
426
+ try {
427
+ this.assertHandshakeCurrent(generation);
428
+ await this.emit("reset");
429
+ this.assertHandshakeCurrent(generation);
430
+ const remoteDeviceInfoSigned = await this.emit("requestDeviceInfo");
431
+ this.assertHandshakeCurrent(generation);
432
+ const remoteDeviceInfo = data_1.deviceInfoSchema.parse((0, keys_1.openSignedObject)(remoteDeviceInfoSigned));
433
+ if (remoteDeviceInfoSigned.publicKey !== remoteDeviceInfo.publicKey) {
434
+ throw new Error("Device info signing key does not match claimed identity key");
435
+ }
436
+ if (remoteDeviceInfo.userId === this.localDevice.userId &&
437
+ remoteDeviceInfo.publicKey !== this.localDevice.publicKey) {
438
+ // The server will refuse this handshake. Decide here, while we still have the signed
439
+ // identity, whether the anchors say this device's key was rotated away.
440
+ await (0, key_change_1.detectIdentityRotatedAway)(this.localDevice, remoteDeviceInfo);
441
+ this.assertHandshakeCurrent(generation);
442
+ }
443
+ const handshake = this.initiateHandshake(remoteAddress, remoteDeviceInfo);
444
+ // The server seals every frame after its reply, and a busy client can read the next one
445
+ // in the same batch as the reply. So the reply is checked and the cipher armed while the
446
+ // reply frame is processed, not after an await.
447
+ const handshakeResponse = await this.request("completeHandshake", [handshake], (box) => this.acceptHandshakeReply(box, remoteAddress, remoteDeviceInfo, generation));
448
+ this.assertHandshakeCurrent(generation);
449
+ const currentRemoteDeviceInfo = this.remoteDeviceInfo;
450
+ await noteHandshakeContest(handshakeResponse.keyContest);
451
+ this.assertHandshakeCurrent(generation);
452
+ const trustLevel = await this.getTrustLevel(currentRemoteDeviceInfo, true);
453
+ this.assertHandshakeCurrent(generation);
454
+ this.trustLevel = trustLevel;
455
+ if (this.trustLevel < socket_type_1.TrustLevel.Unknown) {
456
+ this.resetRemote();
457
+ throw new Error("Untrusted connection");
458
+ }
459
+ return handshakeResponse;
469
460
  }
470
- if (remoteDeviceInfo.userId === this.localDevice.userId &&
471
- remoteDeviceInfo.publicKey !== this.localDevice.publicKey) {
472
- // The server will refuse this handshake. Decide here, while we still have the signed
473
- // identity, whether the anchors say this device's key was rotated away.
474
- await (0, key_change_1.detectIdentityRotatedAway)(this.localDevice, remoteDeviceInfo);
461
+ catch (e) {
462
+ // A failed or timed-out attempt must neither retain its ephemeral secret nor let a
463
+ // late reply authenticate it. Do not clear state belonging to a newer handshake.
464
+ if (generation === this.handshakeGeneration)
465
+ this.reset();
466
+ throw e;
475
467
  }
476
- const handshake = await this.initiateHandshake(remoteAddress, remoteDeviceInfo);
477
- const handshakeResponseBox = await this.emit("completeHandshake", handshake);
478
- const handshakeResponse = data_1.deviceHandshakeSchema.parse(await this.localDevice.openBoxedAndSignedData(handshakeResponseBox));
479
- await noteHandshakeContest(handshakeResponse.keyContest);
468
+ }
469
+ /**
470
+ * Client side: checks the server's handshake reply, arms the frame cipher, and records the
471
+ * verified identity. Runs synchronously while the reply frame is processed, so a sealed
472
+ * frame read right after it finds the cipher armed and the connection verified.
473
+ */
474
+ acceptHandshakeReply(handshakeResponseBox, remoteAddress, remoteDeviceInfo, generation) {
475
+ this.assertHandshakeCurrent(generation);
476
+ const signedResponse = this.localDevice.openBoxWithSecretKey(handshakeResponseBox);
477
+ const handshakeResponse = data_1.deviceHandshakeSchema.parse((0, keys_1.openSignedObject)(signedResponse));
480
478
  if (handshakeResponse.connectionId !== this.connectionId) {
481
479
  throw new Error(`Invalid connectionId ${handshakeResponse.connectionId}, expected ${this.connectionId}`);
482
480
  }
483
- if (remoteDeviceInfo.publicKey !== handshakeResponse.publicKey ||
481
+ if (signedResponse.publicKey !== remoteDeviceInfo.publicKey ||
482
+ remoteDeviceInfo.publicKey !== handshakeResponse.publicKey ||
484
483
  remoteDeviceInfo.publicBoxKey !== handshakeResponse.publicBoxKey) {
485
484
  throw new Error("Inconsistent public keys");
486
485
  }
@@ -488,21 +487,15 @@ class Connection {
488
487
  remoteDeviceInfo.deviceId !== handshakeResponse.deviceId) {
489
488
  throw new Error("Inconsistent device info");
490
489
  }
491
- const currentRemoteDeviceInfo = {
492
- ...remoteDeviceInfo,
493
- userName: handshakeResponse.userName !== undefined
494
- ? handshakeResponse.userName
495
- : remoteDeviceInfo.userName,
496
- deviceName: handshakeResponse.deviceName !== undefined
497
- ? handshakeResponse.deviceName
498
- : remoteDeviceInfo.deviceName,
499
- };
500
- this._remoteDeviceInfo = currentRemoteDeviceInfo;
501
- this._verified = true;
502
- this._connectionAddress = remoteAddress;
503
490
  const pending = this.pendingClientHandshake;
504
491
  this.pendingClientHandshake = undefined;
505
- if (pending && handshakeResponse.frameCrypto && this.socket.armFrameCipher) {
492
+ try {
493
+ if (!handshakeResponse.frameCrypto) {
494
+ throw legacyPeerError(handshakeResponse.deviceId);
495
+ }
496
+ if (!pending) {
497
+ throw new Error("No handshake is pending on this connection");
498
+ }
506
499
  const derived = this.localDevice.deriveFrameKeys({
507
500
  remotePublicBoxKey: handshakeResponse.publicBoxKey,
508
501
  ephemeralSecretKey: pending.ephemeralSecretKey,
@@ -513,29 +506,34 @@ class Connection {
513
506
  timestamp: pending.timestamp,
514
507
  });
515
508
  // The server armed receive before replying and send right after; from
516
- // here on both directions are sealed.
517
- this.socket.armFrameCipher((0, frame_cipher_1.frameKeysForRole)(derived, "client"));
509
+ // here on both directions are sealed. Do not mark verified if deriving or arming fails.
510
+ this.requireFrameCipher()((0, frame_cipher_1.frameKeysForRole)(derived, "client"));
518
511
  }
519
- else {
512
+ finally {
520
513
  pending?.ephemeralSecretKey.fill(0);
521
- this.warnLegacyPeer(handshakeResponse.deviceId);
522
- this.socket.setPlaintextStreamsAllowed?.(true);
523
- }
524
- // Legacy classification, only consulted when the frame cipher is not armed.
525
- const secureProtocols = ["https", "wss", "wrtc"];
526
- this.secureLocal =
527
- secureProtocols.some((s) => remoteAddress.startsWith(s)) &&
528
- handshakeResponse.serverAddress === remoteAddress;
529
- if (this.secureLocal && !this.socket.frameCipherArmed) {
530
- await this.emit("requestSecure");
531
- }
532
- this.trustLevel = await this.getTrustLevel(currentRemoteDeviceInfo, true);
533
- if (this.trustLevel < socket_type_1.TrustLevel.Unknown) {
534
- this.resetRemote();
535
- throw new Error("Untrusted connection");
536
514
  }
515
+ this._remoteDeviceInfo = {
516
+ ...remoteDeviceInfo,
517
+ userName: handshakeResponse.userName !== undefined
518
+ ? handshakeResponse.userName
519
+ : remoteDeviceInfo.userName,
520
+ deviceName: handshakeResponse.deviceName !== undefined
521
+ ? handshakeResponse.deviceName
522
+ : remoteDeviceInfo.deviceName,
523
+ };
524
+ this._verified = true;
525
+ this._connectionAddress = remoteAddress;
537
526
  return handshakeResponse;
538
527
  }
528
+ /**
529
+ * Throws when the connection was reset or closed after the handshake started: a handshake
530
+ * cut short must fail, not finish on a dead or reused connection.
531
+ */
532
+ assertHandshakeCurrent(generation) {
533
+ if (generation !== this.handshakeGeneration || this.socket.connected === false) {
534
+ throw new Error("Connection was closed or reset during the handshake");
535
+ }
536
+ }
539
537
  /**
540
538
  * Resets this side and asks the remote to do the same. The remote call is a courtesy: a
541
539
  * peer that is not a server has no `reset` handler, and a peer we just refused may already
@@ -578,6 +576,10 @@ class Connection {
578
576
  }
579
577
  }
580
578
  exports.Connection = Connection;
579
+ /** Refusal for a peer whose build predates frame encryption (0.25.7). */
580
+ function legacyPeerError(deviceId) {
581
+ return new Error(`Device ${deviceId} does not support frame encryption; update Peers on that device to connect`);
582
+ }
581
583
  async function noteHandshakeContest(contest) {
582
584
  if (!contest)
583
585
  return;
@@ -49,7 +49,7 @@ export declare class Device implements IDeviceInfo {
49
49
  boxDataForDevice(data: any, device: IDeviceInfo): IDataBox;
50
50
  /** Opens a box addressed to this device. @throws on authentication failure */
51
51
  openBoxWithSecretKey(data: IDataBox): any;
52
- /** Signs then boxes `data` for a device: the legacy per-message RPC envelope. */
52
+ /** Signs then boxes `data` for a device, including handshake replies and pairing credentials. */
53
53
  signAndBoxDataForDevice<T>(data: T, device: IDeviceInfo): IDataBox;
54
54
  /** Signs then boxes `data` to a raw X25519 public key (base64url). */
55
55
  signAndBoxDataForKey<T>(data: T, toPublicBoxKey: string): IDataBox;
@@ -60,13 +60,6 @@ export declare class Device implements IDeviceInfo {
60
60
  * zeroizes both secrets and the supplied ephemeral secret key.
61
61
  */
62
62
  deriveFrameKeys(params: DeviceFrameKeyParams): DerivedFrameKeys;
63
- /**
64
- * Unwraps a possibly boxed and/or signed RPC value down to its contents.
65
- * Plain values pass through unchanged.
66
- */
67
- unwrapResponse<T>(response: T | ISignedObject<T> | IDataBox): T;
68
- /** Opens a box addressed to this device and verifies the signed object inside. */
69
- openBoxedAndSignedData<T>(data: IDataBox): T;
70
63
  /** Updates bounded presentation metadata included in future signed identity messages. */
71
64
  setDisplayInfo({ userName, deviceName }: IDeviceDisplayInfo): void;
72
65
  /**
@@ -54,7 +54,7 @@ class Device {
54
54
  openBoxWithSecretKey(data) {
55
55
  return (0, keys_1.openBoxWithSecretKey)(data, this.keys.secretKey);
56
56
  }
57
- /** Signs then boxes `data` for a device: the legacy per-message RPC envelope. */
57
+ /** Signs then boxes `data` for a device, including handshake replies and pairing credentials. */
58
58
  signAndBoxDataForDevice(data, device) {
59
59
  const signedData = this.signObjectWithSecretKey(data);
60
60
  return this.boxDataForDevice(signedData, device);
@@ -89,35 +89,6 @@ class Device {
89
89
  params.ephemeralSecretKey.fill(0);
90
90
  }
91
91
  }
92
- /**
93
- * Unwraps a possibly boxed and/or signed RPC value down to its contents.
94
- * Plain values pass through unchanged.
95
- */
96
- unwrapResponse(response) {
97
- if (!response) {
98
- return response;
99
- }
100
- if (typeof response === "object" &&
101
- "contents" in response &&
102
- "nonce" in response &&
103
- "fromPublicKey" in response) {
104
- const signedResponse = this.openBoxWithSecretKey(response);
105
- return this.unwrapResponse(signedResponse);
106
- }
107
- if (typeof response === "object" &&
108
- "contents" in response &&
109
- "signature" in response &&
110
- "publicKey" in response) {
111
- const signedResponse = response;
112
- return (0, keys_1.openSignedObject)(signedResponse);
113
- }
114
- return response;
115
- }
116
- /** Opens a box addressed to this device and verifies the signed object inside. */
117
- openBoxedAndSignedData(data) {
118
- const signedData = this.openBoxWithSecretKey(data);
119
- return (0, keys_1.openSignedObject)(signedData);
120
- }
121
92
  /** Updates bounded presentation metadata included in future signed identity messages. */
122
93
  setDisplayInfo({ userName, deviceName }) {
123
94
  userName = userName ? userName.slice(0, data_1.DISPLAY_NAME_MAX_LENGTH) : undefined;