@peers-app/peers-sdk 0.25.6 → 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.
- package/dist/data/devices.d.ts +44 -2
- package/dist/data/devices.js +16 -1
- package/dist/device/binary-peer-connection-v2.d.ts +11 -2
- package/dist/device/binary-peer-connection-v2.js +150 -18
- package/dist/device/binary-peer-connection-v2.test.js +217 -5
- package/dist/device/connection.d.ts +79 -9
- package/dist/device/connection.js +185 -26
- package/dist/device/connection.test.js +224 -0
- package/dist/device/device.d.ts +53 -13
- package/dist/device/device.js +55 -2
- package/dist/device/frame-cipher.d.ts +133 -0
- package/dist/device/frame-cipher.js +212 -0
- package/dist/device/frame-cipher.test.d.ts +1 -0
- package/dist/device/frame-cipher.test.js +219 -0
- package/dist/device/socket.type.d.ts +42 -1
- package/dist/device/socket.type.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keys.d.ts +19 -0
- package/dist/keys.js +28 -0
- package/dist/rpc-types.d.ts +2 -0
- package/package.json +2 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
84
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
178
|
-
//
|
|
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
|
-
//
|
|
234
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
});
|