@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.
@@ -1,9 +1,11 @@
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");
5
6
  const binary_peer_connection_v2_1 = require("./binary-peer-connection-v2");
6
7
  const device_1 = require("./device");
8
+ const frame_cipher_1 = require("./frame-cipher");
7
9
  // Mock binary peer for testing - simulates WebRTC peers
8
10
  class MockBinaryPeer {
9
11
  dataHandlers = [];
@@ -12,6 +14,8 @@ class MockBinaryPeer {
12
14
  otherPeer;
13
15
  _bufferedAmount = 0;
14
16
  destroyed = false;
17
+ /** Every frame handed to send(), in order (the "wire tap"). */
18
+ sent = [];
15
19
  on(event, handler) {
16
20
  if (event === "data") {
17
21
  this.dataHandlers.push(handler);
@@ -24,12 +28,13 @@ class MockBinaryPeer {
24
28
  }
25
29
  }
26
30
  send(data) {
31
+ const bytes = typeof data === "string"
32
+ ? Buffer.from(data)
33
+ : Buffer.isBuffer(data)
34
+ ? data
35
+ : Buffer.from(data);
36
+ this.sent.push(bytes);
27
37
  if (this.otherPeer) {
28
- const bytes = typeof data === "string"
29
- ? Buffer.from(data)
30
- : Buffer.isBuffer(data)
31
- ? data
32
- : Buffer.from(data);
33
38
  // Simulate async delivery
34
39
  setImmediate(() => {
35
40
  const handlers = this.otherPeer?.dataHandlers;
@@ -410,6 +415,213 @@ describe("binary-peer-connection-v2", () => {
410
415
  rPeer.destroy();
411
416
  });
412
417
  });
418
+ describe("frame cipher", () => {
419
+ function keyPair() {
420
+ const ab = nacl.randomBytes(32);
421
+ const ba = nacl.randomBytes(32);
422
+ return {
423
+ a: { sendKey: ab.slice(), receiveKey: ba.slice() },
424
+ b: { sendKey: ba.slice(), receiveKey: ab.slice() },
425
+ };
426
+ }
427
+ async function armedPair(protocol, connectionId = "conn") {
428
+ const { iPeer, rPeer } = await getMockPeerPair();
429
+ const socketA = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)(connectionId, iPeer, protocol);
430
+ const socketB = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)(connectionId, rPeer, protocol);
431
+ const keys = keyPair();
432
+ socketA.armFrameCipher?.(keys.a);
433
+ socketB.armFrameCipher?.(keys.b);
434
+ return { iPeer, rPeer, socketA, socketB };
435
+ }
436
+ function emit(socket, eventName, args) {
437
+ return new Promise((resolve, reject) => {
438
+ socket.emit(eventName, args, (err, res) => (err ? reject(err) : resolve(res)));
439
+ });
440
+ }
441
+ for (const protocol of ["ws", "wrtc"]) {
442
+ it(`round-trips RPC and streams when armed on ${protocol}, sealing every frame`, async () => {
443
+ const { iPeer, rPeer, socketA, socketB } = await armedPair(protocol);
444
+ expect(socketA.frameCipherArmed).toBe(true);
445
+ expect(socketB.frameCipherArmed).toBe(true);
446
+ const marker = `RPC-MARKER-${(0, utils_1.newid)()}`;
447
+ socketB.on("echo", (args, callback) => {
448
+ callback(null, { got: args.marker, reply: `${marker}-reply` });
449
+ });
450
+ const result = await emit(socketA, "echo", { marker });
451
+ expect(result).toEqual({ got: marker, reply: `${marker}-reply` });
452
+ const streamMarker = nacl.randomBytes(64);
453
+ const received = new Promise((resolve) => socketB.onRawBytes?.("s", (d) => resolve(d)));
454
+ await socketA.sendRawBytes?.("s", streamMarker);
455
+ expect(Buffer.from(await received)).toEqual(Buffer.from(streamMarker));
456
+ // Wire tap: every frame is FRAME_ENC and neither marker appears on the wire.
457
+ const wire = [...iPeer.sent, ...rPeer.sent];
458
+ expect(wire.length).toBeGreaterThanOrEqual(3);
459
+ for (const frame of wire) {
460
+ expect(frame[0]).toBe(frame_cipher_1.FRAME_ENC);
461
+ expect(frame.indexOf(Buffer.from(marker))).toBe(-1);
462
+ expect(frame.indexOf(Buffer.from(streamMarker))).toBe(-1);
463
+ }
464
+ iPeer.destroy();
465
+ rPeer.destroy();
466
+ });
467
+ }
468
+ it("seals an RPC reply that is sent after the local peer has already closed", async () => {
469
+ const { iPeer, rPeer, socketA, socketB } = await armedPair("wrtc");
470
+ const sentBefore = rPeer.sent.length;
471
+ socketB.on("echo", async (_args, callback) => {
472
+ // Tear down before the reply, as a handshake failure does when the
473
+ // confirmation handler resumes in a later microtask.
474
+ rPeer.destroy();
475
+ expect(socketB.connected).toBe(false);
476
+ await Promise.resolve();
477
+ callback(null, { ok: true });
478
+ });
479
+ await expect(emit(socketA, "echo", { n: 1 })).resolves.toEqual({ ok: true });
480
+ const replies = rPeer.sent.slice(sentBefore);
481
+ expect(replies.length).toBeGreaterThan(0);
482
+ for (const frame of replies) {
483
+ expect(frame[0]).toBe(frame_cipher_1.FRAME_ENC);
484
+ }
485
+ expect(socketB.frameCipherArmed).toBe(false);
486
+ iPeer.destroy();
487
+ });
488
+ it("keeps chunked RPC and stream frames within maxFramePayload including overhead", async () => {
489
+ const { iPeer, rPeer, socketA, socketB } = await armedPair("wrtc");
490
+ const maxFrame = 64 * 1024;
491
+ const large = new Uint8Array(200 * 1024);
492
+ for (let i = 0; i < large.length; i++)
493
+ large[i] = i % 256;
494
+ socketB.on("big", (args, callback) => {
495
+ callback(null, args.length);
496
+ });
497
+ expect(await emit(socketA, "big", large)).toBe(large.length);
498
+ const received = new Promise((resolve) => socketB.onRawBytes?.("big-stream", (d) => resolve(d)));
499
+ await socketA.sendRawBytes?.("big-stream", large);
500
+ expect((await received).length).toBe(large.length);
501
+ expect(iPeer.sent.length).toBeGreaterThan(6);
502
+ for (const frame of iPeer.sent) {
503
+ expect(frame.length).toBeLessThanOrEqual(maxFrame);
504
+ expect(frame[0]).toBe(frame_cipher_1.FRAME_ENC);
505
+ }
506
+ // Bytes the sealed frames are budgeted for are actually used: at least one full frame.
507
+ expect(iPeer.sent.some((f) => f.length === maxFrame)).toBe(true);
508
+ iPeer.destroy();
509
+ rPeer.destroy();
510
+ });
511
+ it("closes the connection when a plaintext frame arrives after arming", async () => {
512
+ const { iPeer } = await getMockPeerPair();
513
+ const socket = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", iPeer, "ws");
514
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
515
+ try {
516
+ socket.armFrameCipher?.(keyPair().a);
517
+ iPeer.receive(new Uint8Array([0x00, 0, 0, 0, 0]));
518
+ expect(iPeer.destroyed).toBe(true);
519
+ expect(socket.connected).toBe(false);
520
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("plaintext frame"));
521
+ }
522
+ finally {
523
+ warn.mockRestore();
524
+ }
525
+ });
526
+ it("closes the connection when a sealed frame arrives before arming", async () => {
527
+ const { iPeer } = await getMockPeerPair();
528
+ (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", iPeer, "ws");
529
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
530
+ try {
531
+ iPeer.receive(new Uint8Array([frame_cipher_1.FRAME_ENC, ...new Uint8Array(20)]));
532
+ expect(iPeer.destroyed).toBe(true);
533
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("before frame cipher"));
534
+ }
535
+ finally {
536
+ warn.mockRestore();
537
+ }
538
+ });
539
+ it("closes the connection when a sealed frame fails authentication", async () => {
540
+ const { iPeer, rPeer, socketA } = await armedPair("ws");
541
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
542
+ try {
543
+ // Re-arm B with unrelated keys so A's frames fail to open.
544
+ const socketB = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", rPeer, "ws");
545
+ socketB.armFrameCipher?.(keyPair().b);
546
+ socketA.emit("x", {}, () => { });
547
+ await (0, utils_1.sleep)(20);
548
+ expect(rPeer.destroyed).toBe(true);
549
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed authentication"));
550
+ }
551
+ finally {
552
+ warn.mockRestore();
553
+ iPeer.destroy();
554
+ }
555
+ });
556
+ it("drops plaintext stream frames when plaintext streams are disallowed and not armed", async () => {
557
+ const { iPeer, rPeer } = await getMockPeerPair();
558
+ const socketA = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", iPeer, "ws");
559
+ const socketB = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", rPeer, "ws");
560
+ socketB.setPlaintextStreamsAllowed?.(false);
561
+ let delivered = 0;
562
+ socketB.onRawBytes?.("gated", () => delivered++);
563
+ await socketA.sendRawBytes?.("gated", new Uint8Array([1, 2, 3]));
564
+ await (0, utils_1.sleep)(20);
565
+ expect(delivered).toBe(0);
566
+ expect(rPeer.destroyed).toBe(false);
567
+ // Plaintext RPC still works before the handshake.
568
+ socketB.on("ping", (_a, cb) => cb(null, "pong"));
569
+ expect(await emit(socketA, "ping", {})).toBe("pong");
570
+ // Re-allowing (legacy peer path) delivers again.
571
+ socketB.setPlaintextStreamsAllowed?.(true);
572
+ await socketA.sendRawBytes?.("gated", new Uint8Array([4]));
573
+ await (0, utils_1.sleep)(20);
574
+ expect(delivered).toBe(1);
575
+ iPeer.destroy();
576
+ rPeer.destroy();
577
+ });
578
+ it("arms send after the reply frame when the callback carries armFrameCipherAfter", async () => {
579
+ const { iPeer, rPeer } = await getMockPeerPair();
580
+ const client = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", iPeer, "ws");
581
+ const server = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", rPeer, "ws");
582
+ const keys = keyPair();
583
+ server.on("completeHandshake", (_args, callback) => {
584
+ // Server: arm receive now, send after this reply (as Connection does).
585
+ server.armFrameCipher?.(keys.b, "receive");
586
+ expect(server.frameCipherArmed).toBe(false);
587
+ callback(null, { ok: true }, { armFrameCipherAfter: keys.b });
588
+ });
589
+ server.on("afterwards", (args, cb) => cb(null, `sealed:${args}`));
590
+ const reply = await emit(client, "completeHandshake", {});
591
+ expect(reply).toEqual({ ok: true });
592
+ // The reply itself was plaintext...
593
+ expect(rPeer.sent.length).toBe(1);
594
+ expect(rPeer.sent[0][0]).not.toBe(frame_cipher_1.FRAME_ENC);
595
+ // ...and the server is now fully armed.
596
+ expect(server.frameCipherArmed).toBe(true);
597
+ // Client arms both on receipt and everything after is sealed both ways.
598
+ client.armFrameCipher?.(keys.a);
599
+ expect(await emit(client, "afterwards", "x")).toBe("sealed:x");
600
+ expect(iPeer.sent[iPeer.sent.length - 1][0]).toBe(frame_cipher_1.FRAME_ENC);
601
+ expect(rPeer.sent[rPeer.sent.length - 1][0]).toBe(frame_cipher_1.FRAME_ENC);
602
+ iPeer.destroy();
603
+ rPeer.destroy();
604
+ });
605
+ it("arms send after the last chunk of a chunked reply", async () => {
606
+ const { iPeer, rPeer } = await getMockPeerPair();
607
+ const client = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", iPeer, "wrtc");
608
+ const server = (0, binary_peer_connection_v2_1.createBinaryPeerSocket)("conn", rPeer, "wrtc");
609
+ const keys = keyPair();
610
+ const bigReply = new Uint8Array(150 * 1024);
611
+ server.on("hs", (_args, callback) => {
612
+ server.armFrameCipher?.(keys.b, "receive");
613
+ callback(null, bigReply, { armFrameCipherAfter: keys.b });
614
+ });
615
+ const reply = await emit(client, "hs", {});
616
+ expect(reply.length).toBe(bigReply.length);
617
+ expect(rPeer.sent.length).toBeGreaterThan(1);
618
+ for (const frame of rPeer.sent)
619
+ expect(frame[0]).not.toBe(frame_cipher_1.FRAME_ENC);
620
+ expect(server.frameCipherArmed).toBe(true);
621
+ iPeer.destroy();
622
+ rPeer.destroy();
623
+ });
624
+ });
413
625
  describe("stats tracking", () => {
414
626
  it("should track bytes sent and received", async () => {
415
627
  const { iPeer, rPeer } = await getMockPeerPair();
@@ -4,38 +4,71 @@ import type { Device } from "./device";
4
4
  import { type ISocket, TrustLevel } from "./socket.type";
5
5
  /** Resolves the trust assigned to an authenticated remote device identity. */
6
6
  export type GetTrustLevel = (deviceInfo: IDeviceInfo, registerNew?: boolean) => Promise<TrustLevel>;
7
- /** Authenticated RPC connection between two Peers devices. */
7
+ /**
8
+ * Authenticated RPC connection between two Peers devices.
9
+ *
10
+ * The handshake (`doHandshake` on the client, `completeHandshake` on the
11
+ * server) authenticates both identities with signed, boxed messages. When both
12
+ * sockets support it, the handshake also agrees on per-connection frame-cipher
13
+ * keys (`frame-cipher.ts`) and arms the socket, after which every frame on the
14
+ * wire is sealed and the legacy per-message sign+box envelope is skipped.
15
+ * Against a peer that does not advertise `frameCrypto` the connection falls
16
+ * back to the legacy behaviour and logs a warning.
17
+ */
8
18
  export declare class Connection {
9
19
  readonly localDevice: Device;
10
20
  readonly localDeviceServerAddresses?: string[] | undefined;
11
21
  protected readonly getTrustLevel: GetTrustLevel;
12
22
  private _verified;
13
23
  private _remoteDeviceInfo;
24
+ /**
25
+ * @deprecated Legacy transport classification, only consulted when the frame
26
+ * cipher is not armed. Removed once legacy peers are no longer supported.
27
+ */
14
28
  secureLocal: boolean;
29
+ /** @deprecated See {@link Connection.secureLocal}. */
15
30
  secureRemote: boolean;
31
+ /**
32
+ * Legacy per-message encryption override. Ignored once the frame cipher is
33
+ * armed. `"dont-encrypt-traffic-after-handshake"` is honoured only under
34
+ * `NODE_ENV=test`.
35
+ * @deprecated Removed with the legacy path.
36
+ */
16
37
  overrideEncryption?: "encrypt-traffic-regardless-of-protocol" | "dont-encrypt-traffic-after-handshake";
38
+ /** Trust assigned to the remote identity once the handshake completes. */
17
39
  trustLevel: TrustLevel;
40
+ /** Server-side hook fired shortly after a successful handshake. */
18
41
  onHandshakeComplete: ((deviceInfo: IDeviceInfo) => void) | undefined;
42
+ /** RPC timeout for {@link Connection.emit}. */
19
43
  timeoutMs: number;
44
+ /** Preferred chunk size for callers streaming raw bytes over this connection. */
20
45
  maxChunkSize: number;
46
+ /** Maximum accepted clock skew between the two devices' handshake timestamps. */
21
47
  handshakeTimestampToleranceMs: number;
22
48
  private _connectionAddress;
49
+ /** Normalized server address this connection was established against. */
23
50
  get connectionAddress(): string | undefined;
51
+ /** Underlying transport socket. */
24
52
  readonly socket: ISocket;
53
+ private pendingClientHandshake;
54
+ /** Server side: keys to arm for sending right after the handshake response frame. */
55
+ private pendingSendArm;
25
56
  constructor(socket: ISocket, localDevice: Device, localDeviceServerAddresses?: string[] | undefined, getTrustLevel?: GetTrustLevel);
57
+ /** True once the remote identity has been authenticated by the handshake. */
26
58
  get verified(): boolean;
59
+ /** True when every frame on this connection is sealed by the frame cipher. */
60
+ get frameCipherArmed(): boolean;
27
61
  /**
28
- * Determines whether to apply application-level encryption (sign + box).
62
+ * Whether to apply the legacy per-message envelope (sign + box) to RPC payloads.
29
63
  *
30
- * Encryption is SKIPPED when:
31
- * - Binary transports (wrtc): Set secureLocal=true & secureRemote=true
32
- * because transport-level encryption (DTLS-SRTP, noise) handles security
33
- * - Secure WebSockets (wss/https): Both sides confirm secure protocol after handshake
34
- *
35
- * Encryption is APPLIED when:
36
- * - Insecure WebSockets (ws/http): Data travels over unencrypted channel
64
+ * Always false once the frame cipher is armed: the frame layer already gives
65
+ * confidentiality, integrity, and sender binding for every frame. Otherwise
66
+ * (legacy peer) the transport classification below applies:
67
+ * - skipped when both sides agreed the transport is secure (`secureLocal && secureRemote`)
68
+ * - applied on plain `ws` / `http`
37
69
  */
38
70
  get encryptTraffic(): boolean;
71
+ /** Copy of the authenticated remote device identity (empty before the handshake). */
39
72
  get remoteDeviceInfo(): {
40
73
  publicKey: string;
41
74
  publicBoxKey: string;
@@ -44,7 +77,11 @@ export declare class Connection {
44
77
  userName?: string | undefined;
45
78
  deviceName?: string | undefined;
46
79
  };
80
+ /** Socket ID shared by both ends; also the frame-cipher AAD and HKDF salt. */
47
81
  get connectionId(): string;
82
+ private get supportsFrameCrypto();
83
+ private warnLegacyPeer;
84
+ private readonly handleCompleteHandshakeRPC;
48
85
  /**
49
86
  * Get connection throughput stats with calculated rates.
50
87
  * Returns bytes/messages sent/received and both average and recent rates.
@@ -63,18 +100,51 @@ export declare class Connection {
63
100
  sendRateMBps: number;
64
101
  receiveRateMBps: number;
65
102
  } | null;
103
+ /** Registers `fn` as an RPC handler under `name` (defaults to `fn.name`). */
66
104
  exposeRPC<T extends Function>(name: string, fn: T): void;
67
105
  exposeRPC<T extends Function>(fn: T): void;
106
+ /** Calls a remote RPC handler and resolves with its result (rejects on error or timeout). */
68
107
  emit<T = any>(eventName: string, ...args: any): Promise<T>;
108
+ /**
109
+ * Registers an RPC handler. Events other than the handshake set are rejected
110
+ * until the connection is verified; payloads use the legacy envelope only
111
+ * when {@link Connection.encryptTraffic} is true.
112
+ */
69
113
  on(eventName: string, handler: Function): void;
114
+ /** Removes RPC handlers for `eventName`, or all handlers when omitted. */
70
115
  removeAllListeners(eventName?: string): void;
116
+ /**
117
+ * Clears the authenticated state so a new handshake can run. Does not disarm
118
+ * an already-armed frame cipher: the socket stays sealed with the current
119
+ * keys and a subsequent handshake replaces them.
120
+ */
71
121
  reset(): void;
122
+ /**
123
+ * Client side: builds the signed handshake (advertising frame-cipher support
124
+ * with a fresh ephemeral key when the socket can arm one) and boxes it to the
125
+ * remote device.
126
+ */
72
127
  initiateHandshake(serverAddress: string, remoteDeviceInfo?: IDeviceInfo): IDataBox;
128
+ /**
129
+ * Server side: verifies the client's handshake, resolves trust, and returns
130
+ * the (unsigned) response for the caller to sign and box. When the client
131
+ * offered `frameCrypto` and this socket can arm a cipher, derives the frame
132
+ * keys, arms the receive direction immediately, and schedules the send
133
+ * direction to arm right after the response frame (see
134
+ * {@link Connection.handleCompleteHandshakeRPC}).
135
+ */
73
136
  completeHandshake(boxedHandshake: IDataBox): Promise<IDeviceHandshake>;
137
+ /**
138
+ * Client side: runs the full handshake against `remoteAddress`. On success
139
+ * the remote identity is verified and, if both sockets support it, the
140
+ * frame cipher is armed in both directions before this resolves.
141
+ */
74
142
  doHandshake(remoteAddress: string): Promise<IDeviceHandshake>;
75
143
  private closeLocal;
144
+ /** Round-trip time in milliseconds. @throws if the connection is not verified */
76
145
  ping(): Promise<number>;
77
146
  private closed;
147
+ /** Notifies the peer (best effort, 100 ms) then tears down the local side. */
78
148
  close(): Promise<void>;
79
149
  }
80
150
  /** Normalizes a peer address for transport and security comparisons. */