@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.
@@ -61,6 +61,28 @@ export declare const deviceInfoSchema: z.ZodObject<{
61
61
  }>;
62
62
  /** Signed identity and optional presentation hints advertised by a device. */
63
63
  export type IDeviceInfo = z.infer<typeof deviceInfoSchema>;
64
+ /**
65
+ * Frame-cipher capability advertised inside a signed handshake.
66
+ *
67
+ * Present when the sender can arm the per-connection frame cipher
68
+ * (`peers-sdk/src/device/frame-cipher.ts`). Because it travels inside the
69
+ * signed and boxed handshake, an on-path attacker cannot strip it without
70
+ * breaking the signature; a missing field means a genuinely legacy peer.
71
+ */
72
+ export declare const handshakeFrameCryptoSchema: z.ZodObject<{
73
+ /** Key-derivation and wire-format version. Only `1` exists. */
74
+ version: z.ZodLiteral<1>;
75
+ /** Ephemeral X25519 public key for this connection, base64url. */
76
+ ephemeralBoxKey: z.ZodString;
77
+ }, "strip", z.ZodTypeAny, {
78
+ version: 1;
79
+ ephemeralBoxKey: string;
80
+ }, {
81
+ version: 1;
82
+ ephemeralBoxKey: string;
83
+ }>;
84
+ /** Frame-cipher capability block carried in a handshake. */
85
+ export type IHandshakeFrameCrypto = z.infer<typeof handshakeFrameCryptoSchema>;
64
86
  /** Schema for connection-bound device handshake contents. */
65
87
  export declare const deviceHandshakeSchema: z.ZodObject<{
66
88
  userId: z.ZodEffects<z.ZodString, string, string>;
@@ -73,26 +95,46 @@ export declare const deviceHandshakeSchema: z.ZodObject<{
73
95
  connectionId: z.ZodString;
74
96
  serverAddress: z.ZodString;
75
97
  timestamp: z.ZodNumber;
98
+ frameCrypto: z.ZodOptional<z.ZodObject<{
99
+ /** Key-derivation and wire-format version. Only `1` exists. */
100
+ version: z.ZodLiteral<1>;
101
+ /** Ephemeral X25519 public key for this connection, base64url. */
102
+ ephemeralBoxKey: z.ZodString;
103
+ }, "strip", z.ZodTypeAny, {
104
+ version: 1;
105
+ ephemeralBoxKey: string;
106
+ }, {
107
+ version: 1;
108
+ ephemeralBoxKey: string;
109
+ }>>;
76
110
  }, "strip", z.ZodTypeAny, {
77
111
  timestamp: number;
78
112
  publicKey: string;
79
113
  publicBoxKey: string;
80
114
  userId: string;
81
- deviceId: string;
82
115
  connectionId: string;
116
+ deviceId: string;
83
117
  serverAddress: string;
84
118
  userName?: string | undefined;
85
119
  deviceName?: string | undefined;
120
+ frameCrypto?: {
121
+ version: 1;
122
+ ephemeralBoxKey: string;
123
+ } | undefined;
86
124
  }, {
87
125
  timestamp: number;
88
126
  publicKey: string;
89
127
  publicBoxKey: string;
90
128
  userId: string;
91
- deviceId: string;
92
129
  connectionId: string;
130
+ deviceId: string;
93
131
  serverAddress: string;
94
132
  userName?: string | undefined;
95
133
  deviceName?: string | undefined;
134
+ frameCrypto?: {
135
+ version: 1;
136
+ ephemeralBoxKey: string;
137
+ } | undefined;
96
138
  }>;
97
139
  /** Signed device information bound to one connection handshake. */
98
140
  export type IDeviceHandshake = z.infer<typeof deviceHandshakeSchema>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.thisDeviceId = exports.trustedServers = exports.deviceHandshakeSchema = exports.deviceInfoSchema = exports.deviceSchema = exports.displayNameSchema = exports.DISPLAY_NAME_MAX_LENGTH = void 0;
3
+ exports.thisDeviceId = exports.trustedServers = exports.deviceHandshakeSchema = exports.handshakeFrameCryptoSchema = exports.deviceInfoSchema = exports.deviceSchema = exports.displayNameSchema = exports.DISPLAY_NAME_MAX_LENGTH = void 0;
4
4
  exports.Devices = Devices;
5
5
  const zod_1 = require("zod");
6
6
  const user_context_singleton_1 = require("../context/user-context-singleton");
@@ -33,11 +33,26 @@ exports.deviceInfoSchema = zod_1.z.object({
33
33
  userName: exports.displayNameSchema.optional(),
34
34
  deviceName: exports.displayNameSchema.optional(),
35
35
  });
36
+ /**
37
+ * Frame-cipher capability advertised inside a signed handshake.
38
+ *
39
+ * Present when the sender can arm the per-connection frame cipher
40
+ * (`peers-sdk/src/device/frame-cipher.ts`). Because it travels inside the
41
+ * signed and boxed handshake, an on-path attacker cannot strip it without
42
+ * breaking the signature; a missing field means a genuinely legacy peer.
43
+ */
44
+ exports.handshakeFrameCryptoSchema = zod_1.z.object({
45
+ /** Key-derivation and wire-format version. Only `1` exists. */
46
+ version: zod_1.z.literal(1),
47
+ /** Ephemeral X25519 public key for this connection, base64url. */
48
+ ephemeralBoxKey: zod_1.z.string(),
49
+ });
36
50
  /** Schema for connection-bound device handshake contents. */
37
51
  exports.deviceHandshakeSchema = exports.deviceInfoSchema.extend({
38
52
  connectionId: zod_1.z.string(),
39
53
  serverAddress: zod_1.z.string(),
40
54
  timestamp: zod_1.z.number(),
55
+ frameCrypto: exports.handshakeFrameCryptoSchema.optional(),
41
56
  });
42
57
  const metaData = {
43
58
  name: "Devices",
@@ -14,6 +14,11 @@
14
14
  *
15
15
  * Both modes share a single sendFrame() function that handles backpressure
16
16
  * and event-loop yielding (critical for Go sidecar WebRTC pipeline).
17
+ *
18
+ * 3. **Frame cipher**: once `armFrameCipher` is called (by `Connection` after
19
+ * the handshake), every outbound frame is sealed as `FRAME_ENC` and every
20
+ * inbound frame must be sealed. See `frame-cipher.ts` and
21
+ * `working/transport-security.md`.
17
22
  */
18
23
  import { Connection } from "./connection";
19
24
  import type { Device } from "./device";
@@ -58,7 +63,9 @@ export interface WrapBinaryPeerOptions {
58
63
  protocol: "wrtc" | "ws";
59
64
  /**
60
65
  * Mark the transport as already secure (encrypted at transport layer).
61
- * When true, sets secureLocal and secureRemote to skip application-level encryption.
66
+ * When true, sets secureLocal and secureRemote to skip the legacy per-message
67
+ * envelope against a legacy peer. Irrelevant once the frame cipher is armed.
68
+ * @deprecated Removed with the legacy path.
62
69
  */
63
70
  markTransportSecure?: boolean;
64
71
  /** Optional hard limits for untrusted inbound messages. */
@@ -76,7 +83,9 @@ export declare function createBinaryPeerSocket(connectionId: string, peer: IBina
76
83
  export declare function wrapBinaryPeer(connectionId: string, peer: IBinaryPeer, localDevice: Device, initiator: boolean, options: WrapBinaryPeerOptions, getTrustLevel?: ITrustLevelFn): Connection;
77
84
  /**
78
85
  * Wraps a WebRTC peer into a Connection.
79
- * WebRTC is encrypted at the transport layer (DTLS-SRTP).
86
+ * WebRTC is encrypted at the transport layer (DTLS-SRTP); the frame cipher is
87
+ * armed on top of it by the handshake so no code path depends on transport
88
+ * classification.
80
89
  */
81
90
  export declare function wrapWrtc(connectionId: string, peer: IWebRTCPeer, localDevice: Device, initiator: boolean, getTrustLevel?: ITrustLevelFn, receiveLimits?: Partial<BinaryPeerReceiveLimits>): Connection;
82
91
  export {};
@@ -15,6 +15,11 @@
15
15
  *
16
16
  * Both modes share a single sendFrame() function that handles backpressure
17
17
  * and event-loop yielding (critical for Go sidecar WebRTC pipeline).
18
+ *
19
+ * 3. **Frame cipher**: once `armFrameCipher` is called (by `Connection` after
20
+ * the handshake), every outbound frame is sealed as `FRAME_ENC` and every
21
+ * inbound frame must be sealed. See `frame-cipher.ts` and
22
+ * `working/transport-security.md`.
18
23
  */
19
24
  Object.defineProperty(exports, "__esModule", { value: true });
20
25
  exports.DEFAULT_BINARY_PEER_RECEIVE_LIMITS = void 0;
@@ -24,6 +29,7 @@ exports.wrapWrtc = wrapWrtc;
24
29
  const msgpack_1 = require("@msgpack/msgpack");
25
30
  const utils_1 = require("../utils");
26
31
  const connection_1 = require("./connection");
32
+ const frame_cipher_1 = require("./frame-cipher");
27
33
  const get_trust_level_fn_1 = require("./get-trust-level-fn");
28
34
  const socket_type_1 = require("./socket.type");
29
35
  const tx_encoding_1 = require("./tx-encoding");
@@ -253,6 +259,71 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
253
259
  throw new Error(`Invalid binary peer receive limit ${name}: ${value}`);
254
260
  }
255
261
  }
262
+ // ─── Frame Cipher ────────────────────────────────────────────────
263
+ // Sealing happens at peer.send() time, so a frame that was built before the
264
+ // cipher was armed but is still waiting on backpressure goes out sealed.
265
+ let sendSealer = null;
266
+ let receiveOpener = null;
267
+ // Bare sockets (no Connection) deliver plaintext streams; Connection turns
268
+ // this off and re-enables it only for a verified legacy peer.
269
+ let plaintextStreamsAllowed = true;
270
+ // Every inner frame must leave room for the cipher's 17 bytes so a sealed
271
+ // frame never exceeds the transport limit (64 KB SCTP on WebRTC). Reserved
272
+ // unconditionally: 17 bytes per frame is nothing and it removes any race
273
+ // between chunk sizing and arming.
274
+ const maxInnerFrame = config.maxFramePayload - frame_cipher_1.FRAME_CIPHER_OVERHEAD;
275
+ /** True once a send cipher has been installed, so a later send cannot fall back to plaintext. */
276
+ let sendCipherWasArmed = false;
277
+ let cipherReleaseTimer;
278
+ function armSend(sendKey) {
279
+ sendCipherWasArmed = true;
280
+ sendSealer?.destroy();
281
+ sendSealer = new frame_cipher_1.FrameSealer(sendKey, connectionId);
282
+ }
283
+ function armReceive(receiveKey) {
284
+ receiveOpener?.destroy();
285
+ receiveOpener = new frame_cipher_1.FrameOpener(receiveKey, connectionId);
286
+ }
287
+ function armFrameCipher(keys, direction = "both") {
288
+ if (isClosed)
289
+ return;
290
+ if (direction !== "send")
291
+ armReceive(keys.receiveKey);
292
+ if (direction !== "receive")
293
+ armSend(keys.sendKey);
294
+ }
295
+ function destroyFrameCipher() {
296
+ if (cipherReleaseTimer) {
297
+ clearImmediate(cipherReleaseTimer);
298
+ cipherReleaseTimer = undefined;
299
+ }
300
+ sendSealer?.destroy();
301
+ receiveOpener?.destroy();
302
+ sendSealer = null;
303
+ receiveOpener = null;
304
+ }
305
+ /**
306
+ * Drop the cipher on a later turn. A close may happen while an inbound RPC
307
+ * handler is awaiting an already-resolved promise; releasing on `setImmediate`
308
+ * lets that handler's reply seal. Sending the reply in the clear after the
309
+ * peer had armed is a protocol violation that closes the other side.
310
+ */
311
+ function releaseCipherAfterTurn() {
312
+ if (cipherReleaseTimer)
313
+ return;
314
+ const sealer = sendSealer;
315
+ const opener = receiveOpener;
316
+ cipherReleaseTimer = setImmediate(() => {
317
+ cipherReleaseTimer = undefined;
318
+ if (sendSealer === sealer)
319
+ sendSealer = null;
320
+ if (receiveOpener === opener)
321
+ receiveOpener = null;
322
+ sealer?.destroy();
323
+ opener?.destroy();
324
+ });
325
+ cipherReleaseTimer.unref?.();
326
+ }
256
327
  // ─── Drain / Backpressure ────────────────────────────────────────
257
328
  // Single persistent drain handler to avoid leaking listeners
258
329
  let drainResolve = null;
@@ -266,8 +337,13 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
266
337
  /**
267
338
  * Send a single frame through the underlying transport with backpressure
268
339
  * and event-loop yielding. This is the ONLY function that calls peer.send().
340
+ *
341
+ * `frame` is an inner frame (type byte first). It is sealed here if the send
342
+ * cipher is armed. `sendOptions.armFrameCipherAfter` arms the send cipher
343
+ * synchronously after this frame has been handed to the transport, so the
344
+ * handshake response goes out in the clear and everything after it sealed.
269
345
  */
270
- async function sendFrame(frame) {
346
+ async function sendFrame(frame, sendOptions) {
271
347
  // Backpressure: wait for buffer to drain before sending more
272
348
  while ((peer.getBufferedAmount?.() ?? 0) > config.highWaterMark) {
273
349
  await new Promise((resolve) => {
@@ -282,9 +358,17 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
282
358
  }, 5);
283
359
  });
284
360
  }
285
- stats.bytesSent += frame.length;
361
+ // A retired cipher must not fall back to a clear frame; the peer would close
362
+ // on it. Drop the send instead.
363
+ if (!sendSealer && sendCipherWasArmed)
364
+ return;
365
+ const wire = sendSealer ? sendSealer.seal(frame) : frame;
366
+ stats.bytesSent += wire.length;
286
367
  stats.messagesSent++;
287
- peer.send(frame);
368
+ peer.send(wire);
369
+ if (sendOptions?.armFrameCipherAfter) {
370
+ armSend(sendOptions.armFrameCipherAfter.sendKey);
371
+ }
288
372
  // Yield to event loop so the receiving side can process I/O.
289
373
  // CRITICAL for Go sidecar pipeline: without this, Node.js sends chunks
290
374
  // in a microtask chain that starves the Unix socket I/O, deadlocking
@@ -300,29 +384,31 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
300
384
  /**
301
385
  * Send an encoded RPC message. Small messages go as single frames.
302
386
  * Large messages are chunked through the queue to prevent interleaving.
387
+ * `sendOptions` apply to the last frame of the message.
303
388
  */
304
- function sendRPC(encoded) {
389
+ function sendRPC(encoded, sendOptions) {
305
390
  // RPC_COMPLETE header overhead is 1 byte
306
- if (encoded.length + 1 <= config.maxFramePayload) {
391
+ if (encoded.length + 1 <= maxInnerFrame) {
307
392
  // Small message: send directly as single frame (no queue needed)
308
- return sendFrame(buildRPCComplete(encoded));
393
+ return sendFrame(buildRPCComplete(encoded), sendOptions);
309
394
  }
310
395
  // Large message: needs chunking. Serialize through queue.
311
396
  const sendChunked = async () => {
312
397
  const msgId = nextMsgId++;
313
398
  // RPC_START header: 1 (type) + 4 (msgId) + 4 (totalSize) = 9 bytes
314
399
  const startHeaderSize = 9;
315
- const firstChunkDataSize = config.maxFramePayload - startHeaderSize;
400
+ const firstChunkDataSize = maxInnerFrame - startHeaderSize;
316
401
  const firstChunkData = encoded.subarray(0, firstChunkDataSize);
317
402
  await sendFrame(buildRPCStart(msgId, encoded.length, firstChunkData));
318
403
  // RPC_CONT header: 1 (type) + 4 (msgId) = 5 bytes
319
404
  const contHeaderSize = 5;
320
- const contChunkDataSize = config.maxFramePayload - contHeaderSize;
405
+ const contChunkDataSize = maxInnerFrame - contHeaderSize;
321
406
  let offset = firstChunkDataSize;
322
407
  while (offset < encoded.length) {
323
408
  const end = Math.min(offset + contChunkDataSize, encoded.length);
324
409
  const chunkData = encoded.subarray(offset, end);
325
- await sendFrame(buildRPCCont(msgId, chunkData));
410
+ const isLast = end === encoded.length;
411
+ await sendFrame(buildRPCCont(msgId, chunkData), isLast ? sendOptions : undefined);
326
412
  offset = end;
327
413
  }
328
414
  };
@@ -353,6 +439,7 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
353
439
  console.warn(`Closing ${protocol} peer after binary protocol violation: ${reason}`);
354
440
  isClosed = true;
355
441
  clearAllReassemblies();
442
+ destroyFrameCipher();
356
443
  socket.connected = false;
357
444
  peer.destroy();
358
445
  }
@@ -406,7 +493,7 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
406
493
  return;
407
494
  }
408
495
  try {
409
- const callback = (err, result) => {
496
+ const callback = (err, result, sendOptions) => {
410
497
  const responseMessage = {
411
498
  type: "callback",
412
499
  callbackId: message.callbackId,
@@ -416,7 +503,7 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
416
503
  else
417
504
  responseMessage.result = result;
418
505
  const encoded = encodeMessage(responseMessage);
419
- sendRPC(encoded);
506
+ sendRPC(encoded, sendOptions);
420
507
  };
421
508
  handler(message.args, callback);
422
509
  }
@@ -439,18 +526,18 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
439
526
  const sidBytes = getStreamIdBytes(streamId);
440
527
  // STREAM_COMPLETE header: 1 (type) + 1 (sidLen) + sidBytes.length
441
528
  const headerSize = 1 + 1 + sidBytes.length;
442
- if (data.length + headerSize <= config.maxFramePayload) {
529
+ if (data.length + headerSize <= maxInnerFrame) {
443
530
  // Single frame
444
531
  await sendFrame(buildStreamComplete(sidBytes, data));
445
532
  }
446
533
  else {
447
534
  // Chunked: STREAM_START has +4 bytes for totalSize
448
535
  const startHeaderSize = headerSize + 4;
449
- const firstDataSize = config.maxFramePayload - startHeaderSize;
536
+ const firstDataSize = maxInnerFrame - startHeaderSize;
450
537
  const firstChunkData = data.subarray(0, firstDataSize);
451
538
  await sendFrame(buildStreamStart(sidBytes, data.length, firstChunkData));
452
539
  // STREAM_CONT has same header as STREAM_COMPLETE (no totalSize)
453
- const contDataSize = config.maxFramePayload - headerSize;
540
+ const contDataSize = maxInnerFrame - headerSize;
454
541
  let offset = firstDataSize;
455
542
  while (offset < data.length) {
456
543
  const end = Math.min(offset + contDataSize, data.length);
@@ -463,12 +550,46 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
463
550
  // ─── Incoming Frame Router ───────────────────────────────────────
464
551
  peer.on("data", (data) => {
465
552
  try {
466
- const bytes = new Uint8Array(data);
553
+ if (isClosed)
554
+ return;
555
+ let bytes = new Uint8Array(data);
467
556
  stats.bytesReceived += bytes.length;
468
557
  stats.messagesReceived++;
469
558
  if (bytes.length === 0)
470
559
  return;
560
+ // ── Frame cipher gate ───────────────────────────────────────
561
+ // Once the receive cipher is armed every frame must be sealed; before
562
+ // that no frame may be. Either violation closes the connection so a
563
+ // peer can neither slip plaintext in after the switch nor pre-empt
564
+ // the handshake with ciphertext.
565
+ if (receiveOpener) {
566
+ if (bytes[0] !== frame_cipher_1.FRAME_ENC) {
567
+ closeForProtocolViolation("plaintext frame received after frame cipher was armed");
568
+ return;
569
+ }
570
+ const inner = receiveOpener.open(bytes);
571
+ if (!inner) {
572
+ closeForProtocolViolation("sealed frame failed authentication");
573
+ return;
574
+ }
575
+ bytes = inner;
576
+ if (bytes.length === 0)
577
+ return;
578
+ }
579
+ else if (bytes[0] === frame_cipher_1.FRAME_ENC) {
580
+ closeForProtocolViolation("sealed frame received before frame cipher was armed");
581
+ return;
582
+ }
471
583
  const frameType = bytes[0];
584
+ // Stream frames bypass Connection's verification gate, so the socket
585
+ // enforces it: deliver only when sealed (handshake done) or when the
586
+ // owner explicitly allows plaintext streams.
587
+ if (frameType >= STREAM_COMPLETE &&
588
+ frameType <= STREAM_CONT &&
589
+ !receiveOpener &&
590
+ !plaintextStreamsAllowed) {
591
+ return;
592
+ }
472
593
  switch (frameType) {
473
594
  // ── RPC frames ──────────────────────────────────────────
474
595
  case RPC_COMPLETE: {
@@ -627,13 +748,15 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
627
748
  console.error(`Error parsing ${protocol} frame:`, err);
628
749
  }
629
750
  });
630
- // Handle closure
751
+ // Handle closure. Cipher teardown waits until queued RPC replies have sealed;
752
+ // see {@link releaseCipherAfterTurn}.
631
753
  peer.on("close", () => {
632
754
  if (isClosed)
633
755
  return;
634
756
  isClosed = true;
635
- clearAllReassemblies();
636
757
  socket.connected = false;
758
+ clearAllReassemblies();
759
+ releaseCipherAfterTurn();
637
760
  });
638
761
  // ─── ISocket Implementation ──────────────────────────────────────
639
762
  const socket = {
@@ -642,6 +765,13 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
642
765
  handlesOwnEncoding: true,
643
766
  transportType,
644
767
  stats,
768
+ armFrameCipher,
769
+ get frameCipherArmed() {
770
+ return sendSealer !== null && receiveOpener !== null;
771
+ },
772
+ setPlaintextStreamsAllowed(allowed) {
773
+ plaintextStreamsAllowed = allowed;
774
+ },
645
775
  emit(eventName, args, callback) {
646
776
  const callbackId = (0, utils_1.newid)();
647
777
  callbacks[callbackId] = callback;
@@ -697,7 +827,9 @@ function wrapBinaryPeer(connectionId, peer, localDevice, initiator, options, get
697
827
  }
698
828
  /**
699
829
  * Wraps a WebRTC peer into a Connection.
700
- * WebRTC is encrypted at the transport layer (DTLS-SRTP).
830
+ * WebRTC is encrypted at the transport layer (DTLS-SRTP); the frame cipher is
831
+ * armed on top of it by the handshake so no code path depends on transport
832
+ * classification.
701
833
  */
702
834
  function wrapWrtc(connectionId, peer, localDevice, initiator, getTrustLevel, receiveLimits) {
703
835
  return wrapBinaryPeer(connectionId, peer, localDevice, initiator, { protocol: "wrtc", markTransportSecure: true, receiveLimits }, getTrustLevel);