@zero-server/webrtc 0.9.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/LICENSE +21 -0
- package/README.md +37 -0
- package/index.d.ts +2 -0
- package/index.js +53 -0
- package/lib/auth/index.js +1 -0
- package/lib/debug.js +372 -0
- package/lib/errors.js +1 -0
- package/lib/middleware/index.js +1 -0
- package/lib/observe/index.js +1 -0
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/lib/ws/index.js +1 -0
- package/package.json +62 -0
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +396 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/webrtc.d.ts +501 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/cluster
|
|
3
|
+
* @description Cluster adapter for the WebRTC signaling hub.
|
|
4
|
+
*
|
|
5
|
+
* `useCluster(hub, adapter)` glues a `SignalingHub` to any pub/sub
|
|
6
|
+
* adapter that implements `{ publish(channel, message), subscribe(
|
|
7
|
+
* channel, cb) -> unsubscribe }`. Once attached:
|
|
8
|
+
*
|
|
9
|
+
* - Every local `join` / `leave` is announced cluster-wide so other
|
|
10
|
+
* nodes can resolve a `peer.id` to its owning node.
|
|
11
|
+
* - Every `room.broadcast(...)` is mirrored to peers in the same room
|
|
12
|
+
* on other nodes.
|
|
13
|
+
* - Direct frames (`offer`, `answer`, `ice`) addressed to a peer that
|
|
14
|
+
* lives on a different node are forwarded to that node's inbox.
|
|
15
|
+
*
|
|
16
|
+
* The adapter itself is intentionally tiny so production deployments
|
|
17
|
+
* can wire it up to Redis, NATS, Kafka, or any in-house bus. A
|
|
18
|
+
* `MemoryClusterAdapter` is provided for tests and single-process
|
|
19
|
+
* simulations.
|
|
20
|
+
*
|
|
21
|
+
* @section Cluster
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const crypto = require('node:crypto');
|
|
27
|
+
|
|
28
|
+
// --- Channel naming ---
|
|
29
|
+
|
|
30
|
+
const CH_ANNOUNCE = 'zs:rtc:announce';
|
|
31
|
+
const chRoom = (name) => `zs:rtc:room:${name}`;
|
|
32
|
+
const chNode = (id) => `zs:rtc:node:${id}`;
|
|
33
|
+
|
|
34
|
+
// --- ClusterCoordinator ---
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-hub cluster glue. Created by {@link useCluster} and parked on
|
|
38
|
+
* `hub._cluster`. Owns the directory of remote peers and the set of
|
|
39
|
+
* pub/sub subscriptions.
|
|
40
|
+
*
|
|
41
|
+
* @class
|
|
42
|
+
* @section Cluster
|
|
43
|
+
*/
|
|
44
|
+
class ClusterCoordinator
|
|
45
|
+
{
|
|
46
|
+
/**
|
|
47
|
+
* @constructor
|
|
48
|
+
* @param {import('./signaling').SignalingHub} hub
|
|
49
|
+
* @param {{publish:Function, subscribe:Function}} adapter
|
|
50
|
+
* @param {object} [opts]
|
|
51
|
+
* @param {string} [opts.nodeId] - Stable id for this node. Defaults to
|
|
52
|
+
* a random 8-byte hex string.
|
|
53
|
+
*/
|
|
54
|
+
constructor(hub, adapter, opts = {})
|
|
55
|
+
{
|
|
56
|
+
/** @type {import('./signaling').SignalingHub} */
|
|
57
|
+
this.hub = hub;
|
|
58
|
+
/** @type {{publish:Function, subscribe:Function}} */
|
|
59
|
+
this.adapter = adapter;
|
|
60
|
+
/** @type {string} */
|
|
61
|
+
this.nodeId = opts.nodeId || crypto.randomBytes(8).toString('hex');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remote peer directory. `peerId -> { nodeId, room }`.
|
|
65
|
+
* @type {Map<string, {nodeId: string, room: string}>}
|
|
66
|
+
*/
|
|
67
|
+
this._remotePeers = new Map();
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, Function|null>} */
|
|
70
|
+
this._roomSubs = new Map();
|
|
71
|
+
|
|
72
|
+
/** @type {Function[]} */
|
|
73
|
+
this._unsubs = [];
|
|
74
|
+
|
|
75
|
+
/** @type {boolean} */
|
|
76
|
+
this._closed = false;
|
|
77
|
+
|
|
78
|
+
this._wire();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @private */
|
|
82
|
+
_wire()
|
|
83
|
+
{
|
|
84
|
+
const offAnnounce = this.adapter.subscribe(CH_ANNOUNCE, (m) => this._onAnnounce(m));
|
|
85
|
+
const offNode = this.adapter.subscribe(chNode(this.nodeId), (m) => this._onNodeMsg(m));
|
|
86
|
+
if (typeof offAnnounce === 'function') this._unsubs.push(offAnnounce);
|
|
87
|
+
if (typeof offNode === 'function') this._unsubs.push(offNode);
|
|
88
|
+
|
|
89
|
+
this._onJoin = ({ peer, room }) =>
|
|
90
|
+
{
|
|
91
|
+
this._ensureRoomSub(room.name);
|
|
92
|
+
this._safePub(CH_ANNOUNCE, {
|
|
93
|
+
kind: 'join', nodeId: this.nodeId, peerId: peer.id, room: room.name,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
this._onLeave = ({ peer, room }) =>
|
|
97
|
+
{
|
|
98
|
+
this._safePub(CH_ANNOUNCE, {
|
|
99
|
+
kind: 'leave', nodeId: this.nodeId, peerId: peer.id, room: room.name,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
this.hub.on('join', this._onJoin);
|
|
103
|
+
this.hub.on('leave', this._onLeave);
|
|
104
|
+
|
|
105
|
+
// Ask existing nodes to rebroadcast their directory.
|
|
106
|
+
this._safePub(CH_ANNOUNCE, { kind: 'hello', nodeId: this.nodeId });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @private */
|
|
110
|
+
_ensureRoomSub(roomName)
|
|
111
|
+
{
|
|
112
|
+
if (this._roomSubs.has(roomName)) return;
|
|
113
|
+
const off = this.adapter.subscribe(chRoom(roomName), (m) => this._onRoomMsg(roomName, m));
|
|
114
|
+
this._roomSubs.set(roomName, typeof off === 'function' ? off : null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @private */
|
|
118
|
+
_onAnnounce(m)
|
|
119
|
+
{
|
|
120
|
+
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
121
|
+
if (m.kind === 'join')
|
|
122
|
+
{
|
|
123
|
+
this._remotePeers.set(m.peerId, { nodeId: m.nodeId, room: m.room });
|
|
124
|
+
this._ensureRoomSub(m.room);
|
|
125
|
+
}
|
|
126
|
+
else if (m.kind === 'leave')
|
|
127
|
+
{
|
|
128
|
+
const entry = this._remotePeers.get(m.peerId);
|
|
129
|
+
if (entry && entry.nodeId === m.nodeId) this._remotePeers.delete(m.peerId);
|
|
130
|
+
}
|
|
131
|
+
else if (m.kind === 'hello')
|
|
132
|
+
{
|
|
133
|
+
// Replay our local directory so the newcomer learns about us.
|
|
134
|
+
for (const peer of this.hub._peers.values())
|
|
135
|
+
{
|
|
136
|
+
if (peer.room)
|
|
137
|
+
{
|
|
138
|
+
this._safePub(CH_ANNOUNCE, {
|
|
139
|
+
kind: 'join', nodeId: this.nodeId, peerId: peer.id, room: peer.room.name,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @private */
|
|
147
|
+
_onNodeMsg(m)
|
|
148
|
+
{
|
|
149
|
+
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
150
|
+
const target = this.hub._peers.get(m.target);
|
|
151
|
+
if (!target) return;
|
|
152
|
+
target.send(m.type, m.payload);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @private */
|
|
156
|
+
_onRoomMsg(roomName, m)
|
|
157
|
+
{
|
|
158
|
+
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
159
|
+
const room = this.hub._rooms.get(roomName);
|
|
160
|
+
if (!room) return;
|
|
161
|
+
for (const p of room._peers)
|
|
162
|
+
{
|
|
163
|
+
if (m.exclude && p.id === m.exclude) continue;
|
|
164
|
+
p.send(m.type, m.payload);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Look up the node that owns a remote peer, if any.
|
|
170
|
+
* @param {string} peerId
|
|
171
|
+
* @returns {{nodeId:string, room:string}|null}
|
|
172
|
+
*/
|
|
173
|
+
locate(peerId)
|
|
174
|
+
{
|
|
175
|
+
return this._remotePeers.get(peerId) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Forward a direct frame to a peer on another node. Called by the
|
|
180
|
+
* hub when a routed message (`offer` / `answer` / `ice`) targets a
|
|
181
|
+
* peer id that is not in the local registry.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} toPeerId
|
|
184
|
+
* @param {string} type
|
|
185
|
+
* @param {object} payload
|
|
186
|
+
* @returns {boolean} `true` if a remote node was addressed.
|
|
187
|
+
*/
|
|
188
|
+
routeDirect(toPeerId, type, payload)
|
|
189
|
+
{
|
|
190
|
+
const entry = this._remotePeers.get(toPeerId);
|
|
191
|
+
if (!entry) return false;
|
|
192
|
+
this._safePub(chNode(entry.nodeId), {
|
|
193
|
+
nodeId: this.nodeId, target: toPeerId, type, payload,
|
|
194
|
+
});
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Mirror a `room.broadcast(...)` to peers in the same room on other
|
|
200
|
+
* nodes. Called automatically from `Room#broadcast`.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} roomName
|
|
203
|
+
* @param {string} type
|
|
204
|
+
* @param {object} payload
|
|
205
|
+
* @param {string} [excludeId]
|
|
206
|
+
*/
|
|
207
|
+
fanoutRoom(roomName, type, payload, excludeId)
|
|
208
|
+
{
|
|
209
|
+
this._safePub(chRoom(roomName), {
|
|
210
|
+
nodeId: this.nodeId, type, payload, exclude: excludeId || null,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Tear down all subscriptions and clear remote state. */
|
|
215
|
+
close()
|
|
216
|
+
{
|
|
217
|
+
if (this._closed) return;
|
|
218
|
+
this._closed = true;
|
|
219
|
+
try { this.hub.off('join', this._onJoin); } catch { /* ignore */ }
|
|
220
|
+
try { this.hub.off('leave', this._onLeave); } catch { /* ignore */ }
|
|
221
|
+
for (const off of this._unsubs) { try { off(); } catch { /* ignore */ } }
|
|
222
|
+
for (const off of this._roomSubs.values())
|
|
223
|
+
{
|
|
224
|
+
if (typeof off === 'function') { try { off(); } catch { /* ignore */ } }
|
|
225
|
+
}
|
|
226
|
+
this._unsubs.length = 0;
|
|
227
|
+
this._roomSubs.clear();
|
|
228
|
+
this._remotePeers.clear();
|
|
229
|
+
if (this.hub._cluster === this) this.hub._cluster = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @private */
|
|
233
|
+
_safePub(channel, message)
|
|
234
|
+
{
|
|
235
|
+
try
|
|
236
|
+
{
|
|
237
|
+
const result = this.adapter.publish(channel, message);
|
|
238
|
+
if (result && typeof result.catch === 'function')
|
|
239
|
+
result.catch((err) => this.hub.emit('clusterError', err));
|
|
240
|
+
}
|
|
241
|
+
catch (err)
|
|
242
|
+
{
|
|
243
|
+
this.hub.emit('clusterError', err);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- useCluster ---
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Attach a cluster adapter to a `SignalingHub`.
|
|
252
|
+
*
|
|
253
|
+
* @param {import('./signaling').SignalingHub} hub
|
|
254
|
+
* @param {{publish:Function, subscribe:Function}} adapter
|
|
255
|
+
* @param {object} [opts]
|
|
256
|
+
* @param {string} [opts.nodeId]
|
|
257
|
+
* @returns {ClusterCoordinator}
|
|
258
|
+
*
|
|
259
|
+
* @section Cluster
|
|
260
|
+
*
|
|
261
|
+
* @example | In-memory cluster (tests / single-process simulation)
|
|
262
|
+
* const { SignalingHub, useCluster, MemoryClusterAdapter } = require('@zero-server/webrtc');
|
|
263
|
+
* const adapter = new MemoryClusterAdapter();
|
|
264
|
+
* const a = new SignalingHub(); useCluster(a, adapter, { nodeId: 'a' });
|
|
265
|
+
* const b = new SignalingHub(); useCluster(b, adapter, { nodeId: 'b' });
|
|
266
|
+
*
|
|
267
|
+
* @example | Redis adapter (BYO ioredis)
|
|
268
|
+
* const Redis = require('ioredis');
|
|
269
|
+
* const pub = new Redis(), sub = new Redis();
|
|
270
|
+
* const adapter = {
|
|
271
|
+
* publish: (ch, msg) => pub.publish(ch, JSON.stringify(msg)),
|
|
272
|
+
* subscribe: (ch, cb) => {
|
|
273
|
+
* sub.subscribe(ch);
|
|
274
|
+
* const on = (c, raw) => { if (c === ch) cb(JSON.parse(raw)); };
|
|
275
|
+
* sub.on('message', on);
|
|
276
|
+
* return () => { sub.off('message', on); sub.unsubscribe(ch); };
|
|
277
|
+
* },
|
|
278
|
+
* };
|
|
279
|
+
* useCluster(hub, adapter);
|
|
280
|
+
*/
|
|
281
|
+
function useCluster(hub, adapter, opts)
|
|
282
|
+
{
|
|
283
|
+
if (!adapter || typeof adapter.publish !== 'function' || typeof adapter.subscribe !== 'function')
|
|
284
|
+
throw new TypeError('useCluster: adapter must implement { publish, subscribe }');
|
|
285
|
+
const coord = new ClusterCoordinator(hub, adapter, opts);
|
|
286
|
+
hub._cluster = coord;
|
|
287
|
+
return coord;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- MemoryClusterAdapter ---
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* In-memory pub/sub adapter for tests and single-process simulations.
|
|
294
|
+
* Delivers synchronously to all subscribers on the same channel.
|
|
295
|
+
*
|
|
296
|
+
* @class
|
|
297
|
+
* @section Cluster
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* const adapter = new MemoryClusterAdapter();
|
|
301
|
+
* useCluster(hubA, adapter); useCluster(hubB, adapter);
|
|
302
|
+
*/
|
|
303
|
+
class MemoryClusterAdapter
|
|
304
|
+
{
|
|
305
|
+
constructor()
|
|
306
|
+
{
|
|
307
|
+
/** @type {Map<string, Function[]>} */
|
|
308
|
+
this._channels = new Map();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} channel
|
|
313
|
+
* @param {*} message
|
|
314
|
+
*/
|
|
315
|
+
publish(channel, message)
|
|
316
|
+
{
|
|
317
|
+
const list = this._channels.get(channel);
|
|
318
|
+
if (!list || list.length === 0) return;
|
|
319
|
+
for (const fn of list.slice())
|
|
320
|
+
{
|
|
321
|
+
try { fn(message); } catch { /* swallow subscriber errors */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {string} channel
|
|
327
|
+
* @param {(msg:*) => void} fn
|
|
328
|
+
* @returns {() => void} unsubscribe
|
|
329
|
+
*/
|
|
330
|
+
subscribe(channel, fn)
|
|
331
|
+
{
|
|
332
|
+
let list = this._channels.get(channel);
|
|
333
|
+
if (!list) { list = []; this._channels.set(channel, list); }
|
|
334
|
+
list.push(fn);
|
|
335
|
+
return () =>
|
|
336
|
+
{
|
|
337
|
+
const cur = this._channels.get(channel);
|
|
338
|
+
if (!cur) return;
|
|
339
|
+
const i = cur.indexOf(fn);
|
|
340
|
+
if (i >= 0) cur.splice(i, 1);
|
|
341
|
+
if (cur.length === 0) this._channels.delete(channel);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
useCluster,
|
|
348
|
+
ClusterCoordinator,
|
|
349
|
+
MemoryClusterAdapter,
|
|
350
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/e2ee
|
|
3
|
+
* @description End-to-end-encrypted key relay channel for WebRTC.
|
|
4
|
+
*
|
|
5
|
+
* The hub never sees plaintext SFrame / Insertable-Streams keys.
|
|
6
|
+
* Publishers wrap each rotation in a sealed envelope (X25519 ECDH +
|
|
7
|
+
* HKDF-SHA-256 + AES-256-GCM) and broadcast it via the `e2ee-key`
|
|
8
|
+
* wire message; subscribers in the same room receive the sealed
|
|
9
|
+
* payload and decrypt locally with their private key.
|
|
10
|
+
*
|
|
11
|
+
* For deployments that use a different sealing primitive (NaCl
|
|
12
|
+
* `crypto_box_seal`, libsignal, etc.) the {@link E2eeChannel} works
|
|
13
|
+
* with any opaque `Buffer` - the {@link sealKey} / {@link openSealedKey}
|
|
14
|
+
* helpers are provided as a zero-dependency default that satisfies the
|
|
15
|
+
* HIPAA / FINRA "server is opaque" requirement.
|
|
16
|
+
*
|
|
17
|
+
* @section E2EE
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
createPublicKey, createPrivateKey,
|
|
24
|
+
generateKeyPairSync,
|
|
25
|
+
diffieHellman,
|
|
26
|
+
hkdfSync,
|
|
27
|
+
createCipheriv, createDecipheriv,
|
|
28
|
+
randomBytes,
|
|
29
|
+
} = require('node:crypto');
|
|
30
|
+
|
|
31
|
+
const { WebRTCError } = require('../errors');
|
|
32
|
+
|
|
33
|
+
// --- Envelope constants ---
|
|
34
|
+
|
|
35
|
+
/** Envelope version byte. */
|
|
36
|
+
const ENVELOPE_VERSION = 0x01;
|
|
37
|
+
|
|
38
|
+
/** Raw X25519 key length, bytes. */
|
|
39
|
+
const X25519_RAW_LEN = 32;
|
|
40
|
+
|
|
41
|
+
/** AES-256-GCM nonce length, bytes. */
|
|
42
|
+
const GCM_NONCE_LEN = 12;
|
|
43
|
+
|
|
44
|
+
/** AES-256-GCM auth tag length, bytes. */
|
|
45
|
+
const GCM_TAG_LEN = 16;
|
|
46
|
+
|
|
47
|
+
/** HKDF salt - a short constant tying the envelope to this project. */
|
|
48
|
+
const HKDF_INFO = Buffer.from('zs-webrtc/e2ee/v1');
|
|
49
|
+
|
|
50
|
+
// --- Raw <-> KeyObject helpers ---
|
|
51
|
+
|
|
52
|
+
function _rawFromPublicKey(pub)
|
|
53
|
+
{
|
|
54
|
+
if (Buffer.isBuffer(pub) && pub.length === X25519_RAW_LEN) return pub;
|
|
55
|
+
const jwk = pub.export({ format: 'jwk' });
|
|
56
|
+
return Buffer.from(jwk.x, 'base64url');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _publicKeyFromRaw(raw)
|
|
60
|
+
{
|
|
61
|
+
return createPublicKey({
|
|
62
|
+
key: { kty: 'OKP', crv: 'X25519', x: raw.toString('base64url') },
|
|
63
|
+
format: 'jwk',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Public crypto helpers ---
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a fresh X25519 keypair suitable for {@link sealKey} /
|
|
71
|
+
* {@link openSealedKey}.
|
|
72
|
+
*
|
|
73
|
+
* @returns {{publicKey: KeyObject, privateKey: KeyObject}}
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* const { publicKey, privateKey } = generateE2eeKeyPair();
|
|
77
|
+
* const wireKey = publicKey.export({ format: 'jwk' }).x; // base64url
|
|
78
|
+
*/
|
|
79
|
+
function generateE2eeKeyPair()
|
|
80
|
+
{
|
|
81
|
+
return generateKeyPairSync('x25519');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Seal an opaque byte string for a single recipient using the project's
|
|
86
|
+
* default envelope (X25519 ECDH + HKDF-SHA-256 + AES-256-GCM).
|
|
87
|
+
*
|
|
88
|
+
* Envelope layout:
|
|
89
|
+
*
|
|
90
|
+
* `[ver:1] [ephPubRaw:32] [nonce:12] [ciphertext:N] [tag:16]`
|
|
91
|
+
*
|
|
92
|
+
* @param {Buffer|Uint8Array} plaintext - The bytes to encrypt.
|
|
93
|
+
* @param {KeyObject|Buffer} recipientPubKey - Recipient's X25519 public
|
|
94
|
+
* key (KeyObject or 32-byte raw).
|
|
95
|
+
* @returns {Buffer} The sealed envelope.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* const sealed = sealKey(Buffer.from(sframeKey), bob.publicKey);
|
|
99
|
+
* peer.e2ee.publish(epoch, sealed);
|
|
100
|
+
*/
|
|
101
|
+
function sealKey(plaintext, recipientPubKey)
|
|
102
|
+
{
|
|
103
|
+
const pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
104
|
+
const pubKO = Buffer.isBuffer(recipientPubKey) ? _publicKeyFromRaw(recipientPubKey) : recipientPubKey;
|
|
105
|
+
|
|
106
|
+
const eph = generateKeyPairSync('x25519');
|
|
107
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: pubKO });
|
|
108
|
+
const ephRaw = _rawFromPublicKey(eph.publicKey);
|
|
109
|
+
const recRaw = _rawFromPublicKey(pubKO);
|
|
110
|
+
|
|
111
|
+
const salt = Buffer.concat([ephRaw, recRaw]);
|
|
112
|
+
const aesKey = Buffer.from(hkdfSync('sha256', shared, salt, HKDF_INFO, 32));
|
|
113
|
+
|
|
114
|
+
const nonce = randomBytes(GCM_NONCE_LEN);
|
|
115
|
+
const cipher = createCipheriv('aes-256-gcm', aesKey, nonce);
|
|
116
|
+
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
117
|
+
const tag = cipher.getAuthTag();
|
|
118
|
+
|
|
119
|
+
return Buffer.concat([Buffer.from([ENVELOPE_VERSION]), ephRaw, nonce, ct, tag]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Open an envelope produced by {@link sealKey} with the recipient's private
|
|
124
|
+
* key. Throws if the envelope is malformed, was sealed for a different
|
|
125
|
+
* recipient, or was tampered with in flight.
|
|
126
|
+
*
|
|
127
|
+
* @param {Buffer|Uint8Array} sealed - Sealed envelope.
|
|
128
|
+
* @param {KeyObject|Buffer} recipientPrivKey - Recipient's X25519 private key.
|
|
129
|
+
* @returns {Buffer} Decrypted plaintext.
|
|
130
|
+
* @throws {WebRTCError} `code='E2EE_OPEN_FAILED'` on any failure.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* const sframeKey = openSealedKey(received.key, bob.privateKey);
|
|
134
|
+
*/
|
|
135
|
+
function openSealedKey(sealed, recipientPrivKey)
|
|
136
|
+
{
|
|
137
|
+
const buf = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
|
|
138
|
+
const minLen = 1 + X25519_RAW_LEN + GCM_NONCE_LEN + GCM_TAG_LEN;
|
|
139
|
+
if (buf.length < minLen)
|
|
140
|
+
throw new WebRTCError('sealed envelope too short', { code: 'E2EE_OPEN_FAILED' });
|
|
141
|
+
if (buf[0] !== ENVELOPE_VERSION)
|
|
142
|
+
throw new WebRTCError(`unsupported envelope version 0x${buf[0].toString(16)}`, { code: 'E2EE_OPEN_FAILED' });
|
|
143
|
+
|
|
144
|
+
const ephRaw = buf.subarray(1, 1 + X25519_RAW_LEN);
|
|
145
|
+
const nonce = buf.subarray(1 + X25519_RAW_LEN, 1 + X25519_RAW_LEN + GCM_NONCE_LEN);
|
|
146
|
+
const tag = buf.subarray(buf.length - GCM_TAG_LEN);
|
|
147
|
+
const ct = buf.subarray(1 + X25519_RAW_LEN + GCM_NONCE_LEN, buf.length - GCM_TAG_LEN);
|
|
148
|
+
|
|
149
|
+
try
|
|
150
|
+
{
|
|
151
|
+
const privKO = Buffer.isBuffer(recipientPrivKey) ? createPrivateKey({ key: recipientPrivKey, format: 'der', type: 'pkcs8' }) : recipientPrivKey;
|
|
152
|
+
const ephPub = _publicKeyFromRaw(ephRaw);
|
|
153
|
+
const shared = diffieHellman({ privateKey: privKO, publicKey: ephPub });
|
|
154
|
+
|
|
155
|
+
const recPubRaw = _rawFromPublicKey(createPublicKey(privKO));
|
|
156
|
+
const salt = Buffer.concat([ephRaw, recPubRaw]);
|
|
157
|
+
const aesKey = Buffer.from(hkdfSync('sha256', shared, salt, HKDF_INFO, 32));
|
|
158
|
+
|
|
159
|
+
const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce);
|
|
160
|
+
decipher.setAuthTag(tag);
|
|
161
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
162
|
+
}
|
|
163
|
+
catch (err)
|
|
164
|
+
{
|
|
165
|
+
throw new WebRTCError(`failed to open sealed envelope: ${err.message}`, { code: 'E2EE_OPEN_FAILED' });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- E2eeChannel ---
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Per-peer view of the room's E2EE key channel. Created by
|
|
173
|
+
* {@link attachE2ee} and parked on `peer.e2ee`.
|
|
174
|
+
*
|
|
175
|
+
* Maintains a monotonically increasing `epoch` that callers may either
|
|
176
|
+
* supply explicitly or let the channel allocate. Every `publish()` is
|
|
177
|
+
* relayed by the hub to all other peers in the same room as an opaque
|
|
178
|
+
* `e2ee-key` frame - the hub never inspects or decrypts the payload.
|
|
179
|
+
*
|
|
180
|
+
* @class
|
|
181
|
+
* @section E2EE
|
|
182
|
+
*/
|
|
183
|
+
class E2eeChannel
|
|
184
|
+
{
|
|
185
|
+
/**
|
|
186
|
+
* @constructor
|
|
187
|
+
* @param {Peer} peer
|
|
188
|
+
* @param {SignalingHub} hub
|
|
189
|
+
*/
|
|
190
|
+
constructor(peer, hub)
|
|
191
|
+
{
|
|
192
|
+
/** @type {Peer} */
|
|
193
|
+
this.peer = peer;
|
|
194
|
+
/** @type {SignalingHub} */
|
|
195
|
+
this.hub = hub;
|
|
196
|
+
/** @type {number} Last published or observed epoch. */
|
|
197
|
+
this.epoch = 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Broadcast a sealed key to the rest of the peer's room.
|
|
202
|
+
*
|
|
203
|
+
* @param {number|null} epoch - Explicit epoch, or `null` to auto-increment.
|
|
204
|
+
* @param {Buffer|Uint8Array|string} key - Sealed bytes (or wire-ready string).
|
|
205
|
+
* @returns {number} The epoch the key was published under.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* const sealed = sealKey(sframeKey, bob.publicKey);
|
|
209
|
+
* const epoch = peer.e2ee.publish(null, sealed);
|
|
210
|
+
*/
|
|
211
|
+
publish(epoch, key)
|
|
212
|
+
{
|
|
213
|
+
const ep = (typeof epoch === 'number') ? epoch : (this.epoch + 1);
|
|
214
|
+
if (ep > this.epoch) this.epoch = ep;
|
|
215
|
+
|
|
216
|
+
let wire;
|
|
217
|
+
if (typeof key === 'string') wire = key;
|
|
218
|
+
else if (Buffer.isBuffer(key)) wire = key.toString('base64');
|
|
219
|
+
else wire = Buffer.from(key).toString('base64');
|
|
220
|
+
|
|
221
|
+
// Route through the hub's authoritative handler so all the usual
|
|
222
|
+
// validation, broadcast, and observability hooks fire.
|
|
223
|
+
this.hub._handleE2eeKey(this.peer, { type: 'e2ee-key', epoch: ep, key: wire });
|
|
224
|
+
return ep;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Receive sealed keys published by *other* peers in the same room.
|
|
229
|
+
*
|
|
230
|
+
* @param {(ev: {from: string, epoch: number, key: Buffer}) => void} fn
|
|
231
|
+
* @returns {() => void} Unsubscribe function.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* peer.e2ee.subscribe(({ from, epoch, key }) => {
|
|
235
|
+
* const sframeKey = openSealedKey(key, myPrivKey);
|
|
236
|
+
* sframeContext.setKey(epoch, sframeKey);
|
|
237
|
+
* });
|
|
238
|
+
*/
|
|
239
|
+
subscribe(fn)
|
|
240
|
+
{
|
|
241
|
+
const listener = (ev) =>
|
|
242
|
+
{
|
|
243
|
+
if (!ev || ev.peer === this.peer) return;
|
|
244
|
+
if (this.peer.room && ev.peer.room !== this.peer.room) return;
|
|
245
|
+
if (ev.epoch > this.epoch) this.epoch = ev.epoch;
|
|
246
|
+
const keyBuf = Buffer.isBuffer(ev.key) ? ev.key : Buffer.from(ev.key, 'base64');
|
|
247
|
+
try { fn({ from: ev.peer.id, epoch: ev.epoch, key: keyBuf }); }
|
|
248
|
+
catch { /* don't let subscriber errors break the hub */ }
|
|
249
|
+
};
|
|
250
|
+
this.hub.on('e2eeKey', listener);
|
|
251
|
+
return () => this.hub.off('e2eeKey', listener);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Install an {@link E2eeChannel} on a peer as `peer.e2ee`. Idempotent: a
|
|
257
|
+
* second call returns the existing channel.
|
|
258
|
+
*
|
|
259
|
+
* @param {Peer} peer
|
|
260
|
+
* @param {SignalingHub} hub
|
|
261
|
+
* @returns {E2eeChannel}
|
|
262
|
+
*
|
|
263
|
+
* @section E2EE
|
|
264
|
+
*
|
|
265
|
+
* @example | Attach E2EE on every join
|
|
266
|
+
* hub.on('join', ({ peer }) => attachE2ee(peer, hub));
|
|
267
|
+
*/
|
|
268
|
+
function attachE2ee(peer, hub)
|
|
269
|
+
{
|
|
270
|
+
if (peer.e2ee instanceof E2eeChannel) return peer.e2ee;
|
|
271
|
+
peer.e2ee = new E2eeChannel(peer, hub);
|
|
272
|
+
return peer.e2ee;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
ENVELOPE_VERSION,
|
|
277
|
+
E2eeChannel,
|
|
278
|
+
attachE2ee,
|
|
279
|
+
generateE2eeKeyPair,
|
|
280
|
+
sealKey,
|
|
281
|
+
openSealedKey,
|
|
282
|
+
};
|