@zero-server/sdk 0.9.5 → 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/README.md +54 -64
- package/index.js +116 -4
- package/lib/app.js +22 -22
- package/lib/auth/authorize.js +11 -11
- package/lib/auth/enrollment.js +5 -5
- package/lib/auth/jwt.js +9 -9
- package/lib/auth/oauth.js +1 -1
- package/lib/auth/session.js +5 -5
- package/lib/auth/trustedDevice.js +2 -2
- package/lib/auth/twoFactor.js +11 -11
- package/lib/auth/webauthn.js +6 -6
- package/lib/body/json.js +1 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/rawBuffer.js +1 -1
- package/lib/body/text.js +1 -1
- package/lib/body/urlencoded.js +3 -3
- package/lib/cli.js +19 -4
- package/lib/cluster.js +3 -3
- package/lib/debug.js +10 -10
- package/lib/env/index.js +11 -11
- package/lib/errors.js +131 -16
- package/lib/fetch/index.js +1 -1
- package/lib/grpc/call.js +14 -14
- package/lib/grpc/client.js +4 -4
- package/lib/grpc/codec.js +7 -7
- package/lib/grpc/credentials.js +2 -2
- package/lib/grpc/frame.js +2 -2
- package/lib/grpc/health.js +3 -3
- package/lib/grpc/index.js +3 -3
- package/lib/grpc/metadata.js +3 -3
- package/lib/grpc/proto.js +5 -5
- package/lib/grpc/reflection.js +2 -2
- package/lib/grpc/server.js +3 -3
- package/lib/grpc/status.js +2 -2
- package/lib/grpc/watch.js +1 -1
- package/lib/http/request.js +13 -13
- package/lib/http/response.js +2 -2
- package/lib/lifecycle.js +5 -5
- package/lib/middleware/compress.js +4 -4
- package/lib/observe/health.js +1 -1
- package/lib/observe/index.js +1 -1
- package/lib/observe/logger.js +3 -3
- package/lib/observe/metrics.js +4 -4
- package/lib/observe/tracing.js +4 -4
- package/lib/orm/adapters/json.js +1 -1
- package/lib/orm/adapters/memory.js +2 -2
- package/lib/orm/adapters/mongo.js +2 -2
- package/lib/orm/adapters/mysql.js +2 -2
- package/lib/orm/adapters/postgres.js +2 -2
- package/lib/orm/adapters/sqlite.js +3 -3
- package/lib/orm/audit.js +1 -1
- package/lib/orm/index.js +7 -7
- package/lib/orm/migrate.js +1 -1
- package/lib/orm/model.js +15 -15
- package/lib/orm/procedures.js +1 -1
- package/lib/orm/profiler.js +1 -1
- package/lib/orm/query.js +9 -9
- package/lib/orm/schema.js +1 -1
- package/lib/orm/seed/data/person.js +1 -1
- package/lib/orm/seed/fake.js +10 -10
- package/lib/orm/seed/index.js +4 -4
- package/lib/orm/seed/rng.js +1 -1
- package/lib/orm/snapshot.js +2 -2
- package/lib/orm/tenancy.js +6 -6
- package/lib/orm/views.js +1 -1
- package/lib/router/index.js +9 -9
- 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/package.json +2 -2
- package/types/body.d.ts +1 -1
- package/types/cli.d.ts +1 -1
- package/types/index.d.ts +16 -4
- package/types/middleware.d.ts +1 -1
- package/types/orm.d.ts +3 -3
- package/types/request.d.ts +3 -3
- package/types/webrtc.d.ts +501 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/ice
|
|
3
|
+
* @description Zero-dependency ICE candidate parser, serializer, and address
|
|
4
|
+
* classifiers (private / loopback / link-local / mDNS), plus a
|
|
5
|
+
* `filterCandidates` helper used by `SignalingHub` to enforce
|
|
6
|
+
* privacy-preserving policies on relayed offers/answers.
|
|
7
|
+
*
|
|
8
|
+
* Candidate grammar follows RFC 8839 §5.1 / RFC 5245 §15.1:
|
|
9
|
+
*
|
|
10
|
+
* candidate-attribute = "candidate" ":" foundation SP component-id
|
|
11
|
+
* SP transport SP priority SP connection-address
|
|
12
|
+
* SP port SP cand-type [SP rel-addr] [SP rel-port]
|
|
13
|
+
* *(SP extension-att-name SP extension-att-value)
|
|
14
|
+
*
|
|
15
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8839
|
|
16
|
+
* @see https://datatracker.ietf.org/doc/html/rfc5245
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { IceError } = require('../errors');
|
|
22
|
+
|
|
23
|
+
// -- Constants -----------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recognised ICE candidate types (RFC 5245).
|
|
27
|
+
* @type {ReadonlyArray<string>}
|
|
28
|
+
*/
|
|
29
|
+
const CANDIDATE_TYPES = Object.freeze(['host', 'srflx', 'prflx', 'relay']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Recognised TCP candidate types (RFC 6544 §4.5).
|
|
33
|
+
* @type {ReadonlyArray<string>}
|
|
34
|
+
*/
|
|
35
|
+
const TCP_TYPES = Object.freeze(['active', 'passive', 'so']);
|
|
36
|
+
|
|
37
|
+
// -- Public types --------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} IceCandidate
|
|
41
|
+
* @property {string} foundation
|
|
42
|
+
* @property {number} component
|
|
43
|
+
* @property {string} transport - 'udp' or 'tcp' (lowercased).
|
|
44
|
+
* @property {number} priority
|
|
45
|
+
* @property {string} address - IPv4, IPv6, or mDNS hostname.
|
|
46
|
+
* @property {number} port
|
|
47
|
+
* @property {string} type - One of CANDIDATE_TYPES.
|
|
48
|
+
* @property {string} [relatedAddress] - From `raddr`.
|
|
49
|
+
* @property {number} [relatedPort] - From `rport`.
|
|
50
|
+
* @property {string} [tcpType] - From `tcptype` (active/passive/so).
|
|
51
|
+
* @property {Object<string,string>} extensions - All other key/value pairs, insertion-ordered.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// =================================================================
|
|
55
|
+
// Parser
|
|
56
|
+
// =================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a single ICE candidate line.
|
|
60
|
+
*
|
|
61
|
+
* Accepts inputs with or without the `a=` SDP-attribute prefix. Returns
|
|
62
|
+
* a plain object; throws `IceError` on any structural problem.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} line - Candidate line, e.g.
|
|
65
|
+
* `candidate:842163049 1 udp 1677729535 192.168.1.5 50000 typ host`.
|
|
66
|
+
* @returns {IceCandidate} Parsed candidate.
|
|
67
|
+
* @throws {IceError} On malformed input.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const c = parseCandidate('candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 192.168.1.5 rport 50000');
|
|
71
|
+
* if (c.type === 'relay') { console.log('relay candidate'); }
|
|
72
|
+
*
|
|
73
|
+
* @section ICE & TURN
|
|
74
|
+
*/
|
|
75
|
+
function parseCandidate(line)
|
|
76
|
+
{
|
|
77
|
+
if (typeof line !== 'string')
|
|
78
|
+
throw new IceError('parseCandidate: input must be a string');
|
|
79
|
+
|
|
80
|
+
let s = line.trim();
|
|
81
|
+
if (s.startsWith('a=')) s = s.slice(2);
|
|
82
|
+
if (!s.startsWith('candidate:'))
|
|
83
|
+
throw new IceError('parseCandidate: missing "candidate:" prefix', { candidate: line });
|
|
84
|
+
s = s.slice('candidate:'.length);
|
|
85
|
+
|
|
86
|
+
const tok = s.split(/\s+/);
|
|
87
|
+
if (tok.length < 8)
|
|
88
|
+
throw new IceError('parseCandidate: too few tokens', { candidate: line });
|
|
89
|
+
|
|
90
|
+
const [foundation, componentStr, transportRaw, priorityStr,
|
|
91
|
+
address, portStr, typKw, type, ...rest] = tok;
|
|
92
|
+
|
|
93
|
+
if (typKw !== 'typ')
|
|
94
|
+
throw new IceError('parseCandidate: expected "typ" keyword', { candidate: line });
|
|
95
|
+
if (!CANDIDATE_TYPES.includes(type))
|
|
96
|
+
throw new IceError(`parseCandidate: unknown type "${type}"`, { candidate: line });
|
|
97
|
+
|
|
98
|
+
const component = Number(componentStr);
|
|
99
|
+
const priority = Number(priorityStr);
|
|
100
|
+
const port = Number(portStr);
|
|
101
|
+
if (!Number.isInteger(component) || component < 0)
|
|
102
|
+
throw new IceError('parseCandidate: invalid component', { candidate: line });
|
|
103
|
+
if (!Number.isFinite(priority))
|
|
104
|
+
throw new IceError('parseCandidate: invalid priority', { candidate: line });
|
|
105
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
106
|
+
throw new IceError('parseCandidate: invalid port', { candidate: line });
|
|
107
|
+
|
|
108
|
+
/** @type {IceCandidate} */
|
|
109
|
+
const out = {
|
|
110
|
+
foundation,
|
|
111
|
+
component,
|
|
112
|
+
transport: transportRaw.toLowerCase(),
|
|
113
|
+
priority,
|
|
114
|
+
address,
|
|
115
|
+
port,
|
|
116
|
+
type,
|
|
117
|
+
extensions: {},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Walk remaining key/value pairs. raddr / rport / tcptype are lifted
|
|
121
|
+
// to named fields; everything else lands in `extensions` in input order.
|
|
122
|
+
for (let i = 0; i < rest.length - 1; i += 2)
|
|
123
|
+
{
|
|
124
|
+
const k = rest[i];
|
|
125
|
+
const v = rest[i + 1];
|
|
126
|
+
if (k === 'raddr') out.relatedAddress = v;
|
|
127
|
+
else if (k === 'rport') out.relatedPort = Number(v);
|
|
128
|
+
else if (k === 'tcptype') out.tcpType = v;
|
|
129
|
+
else out.extensions[k] = v;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// =================================================================
|
|
136
|
+
// Serializer
|
|
137
|
+
// =================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Serialize a parsed candidate back to its canonical line format.
|
|
141
|
+
* Round-trips outputs of `parseCandidate` exactly, including the
|
|
142
|
+
* insertion order of `extensions`.
|
|
143
|
+
*
|
|
144
|
+
* @param {IceCandidate} c - Parsed candidate object.
|
|
145
|
+
* @returns {string} `candidate:...` line (no `a=` prefix).
|
|
146
|
+
* @throws {IceError} If required fields are missing.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* const out = stringifyCandidate(parseCandidate(line));
|
|
150
|
+
*
|
|
151
|
+
* @section ICE & TURN
|
|
152
|
+
*/
|
|
153
|
+
function stringifyCandidate(c)
|
|
154
|
+
{
|
|
155
|
+
if (!c || typeof c !== 'object')
|
|
156
|
+
throw new IceError('stringifyCandidate: input must be an object');
|
|
157
|
+
const required = ['foundation', 'component', 'transport', 'priority', 'address', 'port', 'type'];
|
|
158
|
+
for (const k of required)
|
|
159
|
+
{
|
|
160
|
+
if (c[k] === undefined || c[k] === null)
|
|
161
|
+
throw new IceError(`stringifyCandidate: missing "${k}"`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let s = `candidate:${c.foundation} ${c.component} ${c.transport} ${c.priority} ${c.address} ${c.port} typ ${c.type}`;
|
|
165
|
+
if (c.relatedAddress !== undefined) s += ` raddr ${c.relatedAddress}`;
|
|
166
|
+
if (c.relatedPort !== undefined) s += ` rport ${c.relatedPort}`;
|
|
167
|
+
if (c.tcpType !== undefined) s += ` tcptype ${c.tcpType}`;
|
|
168
|
+
if (c.extensions)
|
|
169
|
+
{
|
|
170
|
+
for (const [k, v] of Object.entries(c.extensions))
|
|
171
|
+
s += ` ${k} ${v}`;
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =================================================================
|
|
177
|
+
// Address classifiers
|
|
178
|
+
// =================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Test whether the address looks like an IPv4 string.
|
|
182
|
+
* @private
|
|
183
|
+
*/
|
|
184
|
+
function _isIPv4(addr)
|
|
185
|
+
{
|
|
186
|
+
if (typeof addr !== 'string') return false;
|
|
187
|
+
const m = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
188
|
+
if (!m) return false;
|
|
189
|
+
for (let i = 1; i <= 4; i++) if (Number(m[i]) > 255) return false;
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Test whether the address looks like an IPv6 string (very permissive).
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
function _isIPv6(addr)
|
|
198
|
+
{
|
|
199
|
+
if (typeof addr !== 'string') return false;
|
|
200
|
+
return addr.includes(':') && /^[0-9a-fA-F:]+$/.test(addr);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* True for RFC 1918, RFC 6598 (CGNAT), and IPv6 ULA (RFC 4193) addresses.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} addr - Address to classify.
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*
|
|
209
|
+
* @section ICE & TURN
|
|
210
|
+
*/
|
|
211
|
+
function isPrivateIp(addr)
|
|
212
|
+
{
|
|
213
|
+
if (_isIPv4(addr))
|
|
214
|
+
{
|
|
215
|
+
const [a, b] = addr.split('.').map(Number);
|
|
216
|
+
if (a === 10) return true;
|
|
217
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
218
|
+
if (a === 192 && b === 168) return true;
|
|
219
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // RFC 6598 CGNAT
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (_isIPv6(addr))
|
|
223
|
+
{
|
|
224
|
+
// fc00::/7 ULA
|
|
225
|
+
const head = addr.toLowerCase().split(':')[0];
|
|
226
|
+
if (head.length === 0) return false;
|
|
227
|
+
const n = parseInt(head, 16);
|
|
228
|
+
return (n & 0xfe00) === 0xfc00;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True for IPv4 127.0.0.0/8 and IPv6 ::1.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} addr - Address to classify.
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*
|
|
239
|
+
* @section ICE & TURN
|
|
240
|
+
*/
|
|
241
|
+
function isLoopbackIp(addr)
|
|
242
|
+
{
|
|
243
|
+
if (_isIPv4(addr)) return addr.startsWith('127.');
|
|
244
|
+
if (_isIPv6(addr)) return addr === '::1' || /^0*:0*:0*:0*:0*:0*:0*:0*1$/.test(addr);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* True for IPv4 169.254/16 and IPv6 fe80::/10.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} addr - Address to classify.
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*
|
|
254
|
+
* @section ICE & TURN
|
|
255
|
+
*/
|
|
256
|
+
function isLinkLocalIp(addr)
|
|
257
|
+
{
|
|
258
|
+
if (_isIPv4(addr)) return addr.startsWith('169.254.');
|
|
259
|
+
if (_isIPv6(addr))
|
|
260
|
+
{
|
|
261
|
+
const head = addr.toLowerCase().split(':')[0];
|
|
262
|
+
if (head.length === 0) return false;
|
|
263
|
+
const n = parseInt(head, 16);
|
|
264
|
+
return (n & 0xffc0) === 0xfe80;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* True for mDNS `.local` hostnames used by browsers to avoid leaking
|
|
271
|
+
* local IPs (Chrome's mDNS ICE candidates - RFC 8624 / draft-ietf-mmusic-mdns-ice-candidates).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} host - Hostname to test.
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*
|
|
276
|
+
* @section ICE & TURN
|
|
277
|
+
*/
|
|
278
|
+
function isMdnsHostname(host)
|
|
279
|
+
{
|
|
280
|
+
if (typeof host !== 'string') return false;
|
|
281
|
+
if (_isIPv4(host) || _isIPv6(host)) return false;
|
|
282
|
+
return host.toLowerCase().endsWith('.local');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// =================================================================
|
|
286
|
+
// Policy filter
|
|
287
|
+
// =================================================================
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @typedef {object} CandidateFilterPolicy
|
|
291
|
+
* @property {boolean} [blockPrivate=false] - Drop private / loopback / link-local addresses.
|
|
292
|
+
* @property {boolean} [blockMdns=false] - Drop `.local` (mDNS) hostnames.
|
|
293
|
+
* @property {boolean} [blockTcp=false] - Drop TCP-transport candidates.
|
|
294
|
+
* @property {ReadonlyArray<string>} [allowedTypes] - Whitelist of `type` values (host/srflx/prflx/relay).
|
|
295
|
+
* @property {number} [maxCandidates] - Cap the number of returned candidates.
|
|
296
|
+
* @property {(c:IceCandidate)=>boolean} [predicate] - Custom drop function (return false to drop).
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Filter an array of candidates (lines or parsed objects) against a policy.
|
|
301
|
+
*
|
|
302
|
+
* Returns the same shape it was given: if you pass strings you get strings
|
|
303
|
+
* back; if you pass parsed objects you get parsed objects back. Unparseable
|
|
304
|
+
* string lines are silently skipped so a single bad candidate never poisons
|
|
305
|
+
* the whole offer.
|
|
306
|
+
*
|
|
307
|
+
* @param {Array<string|IceCandidate>} candidates - Input list.
|
|
308
|
+
* @param {CandidateFilterPolicy} [policy={}] - Policy (all defaults are permissive).
|
|
309
|
+
* @returns {Array<string|IceCandidate>} Surviving candidates, same element shape as input.
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* const safe = filterCandidates(offer.candidates, {
|
|
313
|
+
* blockPrivate: true,
|
|
314
|
+
* blockMdns: true,
|
|
315
|
+
* allowedTypes: ['srflx', 'relay'],
|
|
316
|
+
* });
|
|
317
|
+
*
|
|
318
|
+
* @section ICE & TURN
|
|
319
|
+
*/
|
|
320
|
+
function filterCandidates(candidates, policy = {})
|
|
321
|
+
{
|
|
322
|
+
if (!Array.isArray(candidates)) return [];
|
|
323
|
+
const {
|
|
324
|
+
blockPrivate = false,
|
|
325
|
+
blockMdns = false,
|
|
326
|
+
blockTcp = false,
|
|
327
|
+
allowedTypes,
|
|
328
|
+
maxCandidates,
|
|
329
|
+
predicate,
|
|
330
|
+
} = policy;
|
|
331
|
+
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const item of candidates)
|
|
334
|
+
{
|
|
335
|
+
const isString = typeof item === 'string';
|
|
336
|
+
let parsed;
|
|
337
|
+
try { parsed = isString ? parseCandidate(item) : item; }
|
|
338
|
+
catch { continue; }
|
|
339
|
+
if (!parsed) continue;
|
|
340
|
+
|
|
341
|
+
if (allowedTypes && !allowedTypes.includes(parsed.type)) continue;
|
|
342
|
+
if (blockTcp && parsed.transport === 'tcp') continue;
|
|
343
|
+
if (blockMdns && isMdnsHostname(parsed.address)) continue;
|
|
344
|
+
if (blockPrivate)
|
|
345
|
+
{
|
|
346
|
+
const a = parsed.address;
|
|
347
|
+
const r = parsed.relatedAddress;
|
|
348
|
+
const isLocal = (x) => x && (isPrivateIp(x) || isLoopbackIp(x) || isLinkLocalIp(x));
|
|
349
|
+
if (isLocal(a) || isLocal(r)) continue;
|
|
350
|
+
}
|
|
351
|
+
if (predicate && !predicate(parsed)) continue;
|
|
352
|
+
|
|
353
|
+
out.push(isString ? item : parsed);
|
|
354
|
+
if (maxCandidates && out.length >= maxCandidates) break;
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
parseCandidate,
|
|
361
|
+
stringifyCandidate,
|
|
362
|
+
isPrivateIp,
|
|
363
|
+
isLoopbackIp,
|
|
364
|
+
isLinkLocalIp,
|
|
365
|
+
isMdnsHostname,
|
|
366
|
+
filterCandidates,
|
|
367
|
+
CANDIDATE_TYPES,
|
|
368
|
+
TCP_TYPES,
|
|
369
|
+
IceError,
|
|
370
|
+
};
|