@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.
- package/dist/data/change-tracking.d.ts +9 -9
- package/dist/data/change-tracking.js +13 -32
- 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/dist/types/peer-device.d.ts +5 -3
- package/dist/types/peer-device.js +5 -3
- package/dist/utils.d.ts +20 -2
- package/dist/utils.js +35 -6
- package/dist/utils.test.js +29 -0
- package/package.json +2 -1
|
@@ -77,8 +77,7 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
77
77
|
now?: () => number;
|
|
78
78
|
});
|
|
79
79
|
/**
|
|
80
|
-
* Ensures the table exists
|
|
81
|
-
* rewrites any far-future `createdAt` rows this device still owns.
|
|
80
|
+
* Ensures the table exists and seeds both clocks from durable history.
|
|
82
81
|
*
|
|
83
82
|
* @param force - Re-run table migration and clock seeding
|
|
84
83
|
*/
|
|
@@ -88,16 +87,17 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
88
87
|
* {@link initTable} reseeds from an empty (or newly created) table.
|
|
89
88
|
*/
|
|
90
89
|
dropTableIfExists(): Promise<void>;
|
|
91
|
-
private seedClocksAndRepair;
|
|
92
90
|
/**
|
|
93
|
-
*
|
|
94
|
-
* `createdAt` is
|
|
95
|
-
*
|
|
96
|
-
* old value will pull the repaired rows.
|
|
91
|
+
* Seed the apply clock from `MAX(appliedAt)` and the write clock from the largest
|
|
92
|
+
* `createdAt` that is not far in the future, so a stored row with a bad timestamp
|
|
93
|
+
* cannot push every later local write into the future.
|
|
97
94
|
*
|
|
98
|
-
*
|
|
95
|
+
* Rows are never rewritten here. A change's `createdAt` and `changeId` are its
|
|
96
|
+
* identity across devices: peers that already hold the row would ignore a
|
|
97
|
+
* re-sent copy, so restamping locally would make this device order the change
|
|
98
|
+
* differently from everyone else.
|
|
99
99
|
*/
|
|
100
|
-
|
|
100
|
+
private seedClocks;
|
|
101
101
|
/**
|
|
102
102
|
* Efficiently find which recordIds have been deleted.
|
|
103
103
|
*
|
|
@@ -89,15 +89,14 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
89
89
|
this.dataChanged = this.dataChangedEmitter.event;
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
* Ensures the table exists
|
|
93
|
-
* rewrites any far-future `createdAt` rows this device still owns.
|
|
92
|
+
* Ensures the table exists and seeds both clocks from durable history.
|
|
94
93
|
*
|
|
95
94
|
* @param force - Re-run table migration and clock seeding
|
|
96
95
|
*/
|
|
97
96
|
async initTable(force = false) {
|
|
98
97
|
const created = await super.initTable(force);
|
|
99
98
|
if (!this.clockInit || force) {
|
|
100
|
-
this.clockInit = this.
|
|
99
|
+
this.clockInit = this.seedClocks();
|
|
101
100
|
}
|
|
102
101
|
await this.clockInit;
|
|
103
102
|
return created;
|
|
@@ -110,7 +109,17 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
110
109
|
await super.dropTableIfExists();
|
|
111
110
|
this.clockInit = undefined;
|
|
112
111
|
}
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Seed the apply clock from `MAX(appliedAt)` and the write clock from the largest
|
|
114
|
+
* `createdAt` that is not far in the future, so a stored row with a bad timestamp
|
|
115
|
+
* cannot push every later local write into the future.
|
|
116
|
+
*
|
|
117
|
+
* Rows are never rewritten here. A change's `createdAt` and `changeId` are its
|
|
118
|
+
* identity across devices: peers that already hold the row would ignore a
|
|
119
|
+
* re-sent copy, so restamping locally would make this device order the change
|
|
120
|
+
* differently from everyone else.
|
|
121
|
+
*/
|
|
122
|
+
async seedClocks() {
|
|
114
123
|
const physicalNow = this.clock.now();
|
|
115
124
|
const cutoff = physicalNow + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
116
125
|
const bounds = await this.db.get(`SELECT
|
|
@@ -123,34 +132,6 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
123
132
|
if (bounds?.maxCreatedAt != null) {
|
|
124
133
|
this.clock.seed(Number(bounds.maxCreatedAt));
|
|
125
134
|
}
|
|
126
|
-
await this.repairFutureTimestamps();
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Rewrite local rows whose `createdAt` is beyond the change-timestamp bound.
|
|
130
|
-
* `createdAt` is restamped from {@link clock} (near wall time) and `appliedAt`
|
|
131
|
-
* from the unbounded apply clock so peers whose watermark already passed the
|
|
132
|
-
* old value will pull the repaired rows.
|
|
133
|
-
*
|
|
134
|
-
* @returns Number of rows rewritten
|
|
135
|
-
*/
|
|
136
|
-
async repairFutureTimestamps() {
|
|
137
|
-
await super.initTable();
|
|
138
|
-
const cutoff = this.clock.now() + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
139
|
-
const inBoundMax = await this.db.get(`SELECT MAX(createdAt) as maxCreatedAt FROM "${this.tableName}" WHERE createdAt <= ?`, [cutoff]);
|
|
140
|
-
if (inBoundMax?.maxCreatedAt != null) {
|
|
141
|
-
this.clock.seed(Number(inBoundMax.maxCreatedAt));
|
|
142
|
-
}
|
|
143
|
-
const rows = await this.db.all(`SELECT changeId FROM "${this.tableName}" WHERE createdAt > ? ORDER BY createdAt ASC`, [cutoff]);
|
|
144
|
-
if (rows.length === 0) {
|
|
145
|
-
return 0;
|
|
146
|
-
}
|
|
147
|
-
for (const row of rows) {
|
|
148
|
-
const createdAt = this.clock.tick();
|
|
149
|
-
const appliedAt = this.appliedClock.tick();
|
|
150
|
-
await this.db.exec(`UPDATE "${this.tableName}" SET createdAt = ?, appliedAt = ? WHERE changeId = ?`, [createdAt, appliedAt, row.changeId]);
|
|
151
|
-
}
|
|
152
|
-
console.log(`Repaired ${rows.length} change tracking row(s) with timestamps too far in the future`);
|
|
153
|
-
return rows.length;
|
|
154
135
|
}
|
|
155
136
|
/**
|
|
156
137
|
* Efficiently find which recordIds have been deleted.
|
package/dist/data/devices.d.ts
CHANGED
|
@@ -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>;
|
package/dist/data/devices.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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 <=
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 <=
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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);
|