@decentnetwork/peer 0.1.17 → 0.1.18
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/ice-lite.d.ts +70 -0
- package/dist/ice-lite.js +306 -0
- package/dist/peer.js +235 -7
- package/dist/stun.d.ts +117 -0
- package/dist/stun.js +304 -0
- package/dist/transport/udp.d.ts +29 -0
- package/dist/transport/udp.js +64 -0
- package/dist/turn-creds.d.ts +38 -0
- package/dist/turn-creds.js +49 -0
- package/dist/turn.d.ts +56 -0
- package/dist/turn.js +275 -0
- package/package.json +1 -1
package/dist/stun.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STUN message codec (RFC 5389 + parts of 5766 for TURN integration).
|
|
3
|
+
*
|
|
4
|
+
* Supports the subset we need:
|
|
5
|
+
* - Binding Request / Binding Success Response (RFC 5389) for SRFLX
|
|
6
|
+
* discovery and ICE connectivity checks.
|
|
7
|
+
* - Long-term credential MESSAGE-INTEGRITY (HMAC-SHA1) and FINGERPRINT,
|
|
8
|
+
* needed by the bootnode TURN servers (coturn).
|
|
9
|
+
* - XOR-MAPPED-ADDRESS decoding.
|
|
10
|
+
* - USERNAME, REALM, NONCE, ERROR-CODE attributes (TURN auth dance).
|
|
11
|
+
* - SOFTWARE / FINGERPRINT attributes for being polite.
|
|
12
|
+
*
|
|
13
|
+
* Wire format:
|
|
14
|
+
*
|
|
15
|
+
* 0 1 2 3
|
|
16
|
+
* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
17
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
18
|
+
* |0 0| STUN Message Type | Message Length |
|
|
19
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
20
|
+
* | Magic Cookie |
|
|
21
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
22
|
+
* | |
|
|
23
|
+
* | Transaction ID (96 bits) |
|
|
24
|
+
* | |
|
|
25
|
+
* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
26
|
+
*
|
|
27
|
+
* Attributes follow as { type: u16, length: u16, value: bytes, pad to 4 }.
|
|
28
|
+
*
|
|
29
|
+
* MESSAGE-INTEGRITY is HMAC-SHA1 over the entire message up to (not
|
|
30
|
+
* including) the MESSAGE-INTEGRITY attribute itself, with the message
|
|
31
|
+
* length pre-adjusted as if MESSAGE-INTEGRITY were present.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash, createHmac, randomBytes as nodeRandomBytes } from "crypto";
|
|
34
|
+
import { concatBytes } from "./utils/bytes.js";
|
|
35
|
+
export const STUN_MAGIC_COOKIE = 0x2112a442;
|
|
36
|
+
export const STUN_BINDING_REQUEST = 0x0001;
|
|
37
|
+
export const STUN_BINDING_SUCCESS = 0x0101;
|
|
38
|
+
export const STUN_BINDING_ERROR = 0x0111;
|
|
39
|
+
// TURN methods (RFC 5766) — declared here so turn.ts can share the codec.
|
|
40
|
+
export const TURN_ALLOCATE_REQUEST = 0x0003;
|
|
41
|
+
export const TURN_ALLOCATE_SUCCESS = 0x0103;
|
|
42
|
+
export const TURN_ALLOCATE_ERROR = 0x0113;
|
|
43
|
+
export const TURN_REFRESH_REQUEST = 0x0004;
|
|
44
|
+
export const TURN_REFRESH_SUCCESS = 0x0104;
|
|
45
|
+
export const TURN_CREATE_PERMISSION_REQUEST = 0x0008;
|
|
46
|
+
export const TURN_CREATE_PERMISSION_SUCCESS = 0x0108;
|
|
47
|
+
export const TURN_SEND_INDICATION = 0x0016;
|
|
48
|
+
export const TURN_DATA_INDICATION = 0x0017;
|
|
49
|
+
export const STUN_ATTR_MAPPED_ADDRESS = 0x0001;
|
|
50
|
+
export const STUN_ATTR_USERNAME = 0x0006;
|
|
51
|
+
export const STUN_ATTR_MESSAGE_INTEGRITY = 0x0008;
|
|
52
|
+
export const STUN_ATTR_ERROR_CODE = 0x0009;
|
|
53
|
+
export const STUN_ATTR_REALM = 0x0014;
|
|
54
|
+
export const STUN_ATTR_NONCE = 0x0015;
|
|
55
|
+
export const STUN_ATTR_XOR_MAPPED_ADDRESS = 0x0020;
|
|
56
|
+
export const STUN_ATTR_SOFTWARE = 0x8022;
|
|
57
|
+
export const STUN_ATTR_FINGERPRINT = 0x8028;
|
|
58
|
+
// TURN attributes
|
|
59
|
+
export const STUN_ATTR_CHANNEL_NUMBER = 0x000c;
|
|
60
|
+
export const STUN_ATTR_LIFETIME = 0x000d;
|
|
61
|
+
export const STUN_ATTR_XOR_PEER_ADDRESS = 0x0012;
|
|
62
|
+
export const STUN_ATTR_DATA = 0x0013;
|
|
63
|
+
export const STUN_ATTR_XOR_RELAYED_ADDRESS = 0x0016;
|
|
64
|
+
export const STUN_ATTR_REQUESTED_TRANSPORT = 0x0019;
|
|
65
|
+
export const STUN_ATTR_DONT_FRAGMENT = 0x001a;
|
|
66
|
+
const FINGERPRINT_XOR = 0x5354554e; // "STUN" — RFC 5389 §15.5
|
|
67
|
+
function u16(n) {
|
|
68
|
+
return Uint8Array.of((n >> 8) & 0xff, n & 0xff);
|
|
69
|
+
}
|
|
70
|
+
function u32(n) {
|
|
71
|
+
return Uint8Array.of((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
|
72
|
+
}
|
|
73
|
+
function padTo4(n) {
|
|
74
|
+
return (4 - (n & 3)) & 3;
|
|
75
|
+
}
|
|
76
|
+
function readU16(b, off) {
|
|
77
|
+
return (b[off] << 8) | b[off + 1];
|
|
78
|
+
}
|
|
79
|
+
function readU32(b, off) {
|
|
80
|
+
return (((b[off] << 24) >>> 0) +
|
|
81
|
+
((b[off + 1] << 16) >>> 0) +
|
|
82
|
+
((b[off + 2] << 8) >>> 0) +
|
|
83
|
+
b[off + 3]);
|
|
84
|
+
}
|
|
85
|
+
export function newTransactionId() {
|
|
86
|
+
return Uint8Array.from(nodeRandomBytes(12));
|
|
87
|
+
}
|
|
88
|
+
function encodeAttributes(attrs) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (const attr of attrs) {
|
|
91
|
+
parts.push(u16(attr.type));
|
|
92
|
+
parts.push(u16(attr.value.length));
|
|
93
|
+
parts.push(attr.value);
|
|
94
|
+
const pad = padTo4(attr.value.length);
|
|
95
|
+
if (pad > 0) {
|
|
96
|
+
parts.push(new Uint8Array(pad));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return concatBytes(parts);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Encode a STUN message. If `integrityKey` is provided, a
|
|
103
|
+
* MESSAGE-INTEGRITY attribute (HMAC-SHA1) is appended. If `fingerprint`
|
|
104
|
+
* is true, a FINGERPRINT (CRC32 XOR 0x5354554e) is appended last.
|
|
105
|
+
*
|
|
106
|
+
* The order matters because MESSAGE-INTEGRITY must be computed over
|
|
107
|
+
* the message *as if* it were the last attribute (with length already
|
|
108
|
+
* adjusted), and FINGERPRINT must be computed over the message
|
|
109
|
+
* including MESSAGE-INTEGRITY.
|
|
110
|
+
*/
|
|
111
|
+
export function encodeStun(message, opts = {}) {
|
|
112
|
+
let body = encodeAttributes(message.attributes);
|
|
113
|
+
if (opts.integrityKey) {
|
|
114
|
+
// Reserve 24 bytes (4-byte header + 20-byte HMAC-SHA1) for MESSAGE-INTEGRITY.
|
|
115
|
+
const reserved = 24;
|
|
116
|
+
const lengthWithIntegrity = body.length + reserved + (opts.fingerprint ? 8 : 0);
|
|
117
|
+
const headerForHmac = concatBytes([
|
|
118
|
+
u16(message.type),
|
|
119
|
+
u16(body.length + reserved),
|
|
120
|
+
u32(STUN_MAGIC_COOKIE),
|
|
121
|
+
message.transactionId
|
|
122
|
+
]);
|
|
123
|
+
const hmacInput = concatBytes([headerForHmac, body]);
|
|
124
|
+
const hmac = createHmac("sha1", Buffer.from(opts.integrityKey)).update(hmacInput).digest();
|
|
125
|
+
body = concatBytes([
|
|
126
|
+
body,
|
|
127
|
+
u16(STUN_ATTR_MESSAGE_INTEGRITY),
|
|
128
|
+
u16(20),
|
|
129
|
+
Uint8Array.from(hmac)
|
|
130
|
+
]);
|
|
131
|
+
void lengthWithIntegrity; // computed for clarity; final length set below
|
|
132
|
+
}
|
|
133
|
+
if (opts.fingerprint) {
|
|
134
|
+
const lengthWithFingerprint = body.length + 8;
|
|
135
|
+
const headerForCrc = concatBytes([
|
|
136
|
+
u16(message.type),
|
|
137
|
+
u16(lengthWithFingerprint),
|
|
138
|
+
u32(STUN_MAGIC_COOKIE),
|
|
139
|
+
message.transactionId
|
|
140
|
+
]);
|
|
141
|
+
const crcInput = concatBytes([headerForCrc, body]);
|
|
142
|
+
const crc = crc32(crcInput) ^ FINGERPRINT_XOR;
|
|
143
|
+
body = concatBytes([
|
|
144
|
+
body,
|
|
145
|
+
u16(STUN_ATTR_FINGERPRINT),
|
|
146
|
+
u16(4),
|
|
147
|
+
u32(crc >>> 0)
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
const header = concatBytes([
|
|
151
|
+
u16(message.type),
|
|
152
|
+
u16(body.length),
|
|
153
|
+
u32(STUN_MAGIC_COOKIE),
|
|
154
|
+
message.transactionId
|
|
155
|
+
]);
|
|
156
|
+
return concatBytes([header, body]);
|
|
157
|
+
}
|
|
158
|
+
export function decodeStun(data) {
|
|
159
|
+
if (data.length < 20)
|
|
160
|
+
return undefined;
|
|
161
|
+
if ((data[0] & 0xc0) !== 0)
|
|
162
|
+
return undefined; // top two bits must be zero
|
|
163
|
+
const type = readU16(data, 0);
|
|
164
|
+
const length = readU16(data, 2);
|
|
165
|
+
const cookie = readU32(data, 4);
|
|
166
|
+
if (cookie !== STUN_MAGIC_COOKIE)
|
|
167
|
+
return undefined;
|
|
168
|
+
if (data.length < 20 + length)
|
|
169
|
+
return undefined;
|
|
170
|
+
const transactionId = data.slice(8, 20);
|
|
171
|
+
const attributes = [];
|
|
172
|
+
let off = 20;
|
|
173
|
+
const end = 20 + length;
|
|
174
|
+
while (off + 4 <= end) {
|
|
175
|
+
const attrType = readU16(data, off);
|
|
176
|
+
const attrLen = readU16(data, off + 2);
|
|
177
|
+
off += 4;
|
|
178
|
+
if (off + attrLen > end)
|
|
179
|
+
return undefined;
|
|
180
|
+
attributes.push({ type: attrType, value: data.slice(off, off + attrLen) });
|
|
181
|
+
off += attrLen + padTo4(attrLen);
|
|
182
|
+
}
|
|
183
|
+
return { type, transactionId, attributes };
|
|
184
|
+
}
|
|
185
|
+
export function findAttr(message, type) {
|
|
186
|
+
for (const a of message.attributes) {
|
|
187
|
+
if (a.type === type)
|
|
188
|
+
return a.value;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Decode XOR-MAPPED-ADDRESS (RFC 5389 §15.2). The high bits of the
|
|
194
|
+
* port and the address are XORed with the magic cookie + transactionId.
|
|
195
|
+
*/
|
|
196
|
+
export function decodeXorMappedAddress(value, transactionId) {
|
|
197
|
+
if (value.length < 4)
|
|
198
|
+
return undefined;
|
|
199
|
+
const family = value[1];
|
|
200
|
+
const xPort = readU16(value, 2);
|
|
201
|
+
const port = xPort ^ ((STUN_MAGIC_COOKIE >>> 16) & 0xffff);
|
|
202
|
+
if (family === 0x01 && value.length >= 8) {
|
|
203
|
+
// IPv4
|
|
204
|
+
const xAddr = value.slice(4, 8);
|
|
205
|
+
const cookieBytes = u32(STUN_MAGIC_COOKIE);
|
|
206
|
+
const a = xAddr.map((b, i) => b ^ cookieBytes[i]);
|
|
207
|
+
return { family: 4, address: `${a[0]}.${a[1]}.${a[2]}.${a[3]}`, port };
|
|
208
|
+
}
|
|
209
|
+
if (family === 0x02 && value.length >= 20) {
|
|
210
|
+
// IPv6
|
|
211
|
+
const xAddr = value.slice(4, 20);
|
|
212
|
+
const xorKey = concatBytes([u32(STUN_MAGIC_COOKIE), transactionId]);
|
|
213
|
+
const a = xAddr.map((b, i) => b ^ xorKey[i]);
|
|
214
|
+
const parts = [];
|
|
215
|
+
for (let i = 0; i < 16; i += 2) {
|
|
216
|
+
parts.push(((a[i] << 8) | a[i + 1]).toString(16));
|
|
217
|
+
}
|
|
218
|
+
return { family: 6, address: parts.join(":"), port };
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
/** Encode XOR-MAPPED-ADDRESS for outgoing STUN responses (TURN, conn check). */
|
|
223
|
+
export function encodeXorMappedAddress(addr, transactionId) {
|
|
224
|
+
if (addr.family === 4) {
|
|
225
|
+
const octets = addr.address.split(".").map((s) => parseInt(s, 10));
|
|
226
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o))) {
|
|
227
|
+
throw new Error(`Invalid IPv4 address: ${addr.address}`);
|
|
228
|
+
}
|
|
229
|
+
const cookieBytes = u32(STUN_MAGIC_COOKIE);
|
|
230
|
+
const xAddr = Uint8Array.from(octets.map((b, i) => b ^ cookieBytes[i]));
|
|
231
|
+
const xPort = addr.port ^ ((STUN_MAGIC_COOKIE >>> 16) & 0xffff);
|
|
232
|
+
return concatBytes([Uint8Array.of(0x00, 0x01), u16(xPort), xAddr]);
|
|
233
|
+
}
|
|
234
|
+
throw new Error("IPv6 XOR-MAPPED-ADDRESS encode not implemented yet");
|
|
235
|
+
}
|
|
236
|
+
export function encodeErrorCode(code, reason) {
|
|
237
|
+
// RFC 5389 §15.6: 0x0000, class (top 3 bits of byte 2), number, reason text
|
|
238
|
+
const cls = Math.floor(code / 100);
|
|
239
|
+
const num = code % 100;
|
|
240
|
+
const reasonBytes = Buffer.from(reason, "utf8");
|
|
241
|
+
return concatBytes([Uint8Array.of(0, 0, cls, num), Uint8Array.from(reasonBytes)]);
|
|
242
|
+
}
|
|
243
|
+
export function decodeErrorCode(value) {
|
|
244
|
+
if (value.length < 4)
|
|
245
|
+
return undefined;
|
|
246
|
+
const code = value[2] * 100 + value[3];
|
|
247
|
+
const reason = Buffer.from(value.slice(4)).toString("utf8");
|
|
248
|
+
return { code, reason };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Long-term-credential integrity key: MD5("username:realm:password").
|
|
252
|
+
* RFC 5389 §15.4. coturn uses this.
|
|
253
|
+
*/
|
|
254
|
+
export function longTermIntegrityKey(username, realm, password) {
|
|
255
|
+
const md5 = createHash("md5");
|
|
256
|
+
md5.update(`${username}:${realm}:${password}`);
|
|
257
|
+
return Uint8Array.from(md5.digest());
|
|
258
|
+
}
|
|
259
|
+
/** Short-term-credential integrity key: just the password as UTF-8. */
|
|
260
|
+
export function shortTermIntegrityKey(password) {
|
|
261
|
+
return Uint8Array.from(Buffer.from(password, "utf8"));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* CRC32 (IEEE 802.3, polynomial 0xedb88320) — for STUN FINGERPRINT.
|
|
265
|
+
* Self-contained so we don't pull in zlib for one tiny use.
|
|
266
|
+
*/
|
|
267
|
+
const CRC32_TABLE = (() => {
|
|
268
|
+
const t = new Uint32Array(256);
|
|
269
|
+
for (let i = 0; i < 256; i++) {
|
|
270
|
+
let c = i;
|
|
271
|
+
for (let j = 0; j < 8; j++) {
|
|
272
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
273
|
+
}
|
|
274
|
+
t[i] = c >>> 0;
|
|
275
|
+
}
|
|
276
|
+
return t;
|
|
277
|
+
})();
|
|
278
|
+
function crc32(data) {
|
|
279
|
+
let c = 0xffffffff;
|
|
280
|
+
for (let i = 0; i < data.length; i++) {
|
|
281
|
+
c = CRC32_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
|
282
|
+
}
|
|
283
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
284
|
+
}
|
|
285
|
+
// Convenience builders ------------------------------------------------------
|
|
286
|
+
export function buildBindingRequest(transactionId) {
|
|
287
|
+
return encodeStun({
|
|
288
|
+
type: STUN_BINDING_REQUEST,
|
|
289
|
+
transactionId: transactionId ?? newTransactionId(),
|
|
290
|
+
attributes: []
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
export function buildBindingRequestWithIntegrity(opts) {
|
|
294
|
+
return encodeStun({
|
|
295
|
+
type: STUN_BINDING_REQUEST,
|
|
296
|
+
transactionId: opts.transactionId ?? newTransactionId(),
|
|
297
|
+
attributes: [
|
|
298
|
+
{ type: STUN_ATTR_USERNAME, value: Uint8Array.from(Buffer.from(opts.username, "utf8")) }
|
|
299
|
+
]
|
|
300
|
+
}, {
|
|
301
|
+
integrityKey: shortTermIntegrityKey(opts.password),
|
|
302
|
+
fingerprint: true
|
|
303
|
+
});
|
|
304
|
+
}
|
package/dist/transport/udp.d.ts
CHANGED
|
@@ -8,6 +8,25 @@ export type UdpTransportOptions = {
|
|
|
8
8
|
host?: string;
|
|
9
9
|
port?: number;
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* A consumer that wants first dibs on inbound datagrams that look like
|
|
13
|
+
* STUN/TURN (RFC 5389 magic cookie at offset 4). ICE agents and TURN
|
|
14
|
+
* clients register here so net_crypto/DHT never sees their control
|
|
15
|
+
* packets, and so TURN DATA-INDICATIONs get unwrapped before the inner
|
|
16
|
+
* app packet is re-emitted as a normal datagram.
|
|
17
|
+
*
|
|
18
|
+
* Return true if the datagram was consumed (stop further dispatch).
|
|
19
|
+
*/
|
|
20
|
+
export type StunInterceptor = (data: Buffer, remote: RemoteInfo) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* A TURN route: outbound datagrams addressed to `${host}:${port}` are
|
|
23
|
+
* handed to `send` instead of going out the raw socket. Used to tunnel
|
|
24
|
+
* net_crypto traffic through a TURN relay transparently — net_crypto
|
|
25
|
+
* sets session.remote to the peer's relay address and is none the wiser.
|
|
26
|
+
*/
|
|
27
|
+
export type TurnRoute = {
|
|
28
|
+
send: (data: Buffer) => void;
|
|
29
|
+
};
|
|
11
30
|
export declare class UdpTransport extends EventEmitter {
|
|
12
31
|
#private;
|
|
13
32
|
constructor();
|
|
@@ -21,4 +40,14 @@ export declare class UdpTransport extends EventEmitter {
|
|
|
21
40
|
*/
|
|
22
41
|
localPort(): number | undefined;
|
|
23
42
|
send(data: Buffer, host: string, port: number): Promise<void>;
|
|
43
|
+
/** Direct socket send, bypassing TURN routes. ICE/TURN use this so
|
|
44
|
+
* their own control packets (SEND-INDICATION, binding checks) don't
|
|
45
|
+
* recurse back through the route table. */
|
|
46
|
+
sendDirect(data: Buffer, host: string, port: number): Promise<void>;
|
|
47
|
+
/** Synchronous fire-and-forget direct send (for hot TURN data path). */
|
|
48
|
+
sendDirectSync(data: Buffer, host: string, port: number): void;
|
|
49
|
+
addStunInterceptor(fn: StunInterceptor): void;
|
|
50
|
+
removeStunInterceptor(fn: StunInterceptor): void;
|
|
51
|
+
registerTurnRoute(host: string, port: number, route: TurnRoute): void;
|
|
52
|
+
unregisterTurnRoute(host: string, port: number): void;
|
|
24
53
|
}
|
package/dist/transport/udp.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import dgram from "node:dgram";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
const STUN_MAGIC_COOKIE_BYTES = Uint8Array.of(0x21, 0x12, 0xa4, 0x42);
|
|
4
|
+
function looksLikeStun(data) {
|
|
5
|
+
// RFC 7983 demux: STUN/TURN packets have the two high bits of byte 0
|
|
6
|
+
// clear AND the magic cookie at offset 4. net_crypto/DHT packets use
|
|
7
|
+
// a single type byte at offset 0 with no such cookie, so this is a
|
|
8
|
+
// reliable disambiguator on a shared socket.
|
|
9
|
+
if (data.length < 8)
|
|
10
|
+
return false;
|
|
11
|
+
if ((data[0] & 0xc0) !== 0)
|
|
12
|
+
return false;
|
|
13
|
+
return (data[4] === STUN_MAGIC_COOKIE_BYTES[0] &&
|
|
14
|
+
data[5] === STUN_MAGIC_COOKIE_BYTES[1] &&
|
|
15
|
+
data[6] === STUN_MAGIC_COOKIE_BYTES[2] &&
|
|
16
|
+
data[7] === STUN_MAGIC_COOKIE_BYTES[3]);
|
|
17
|
+
}
|
|
3
18
|
export class UdpTransport extends EventEmitter {
|
|
4
19
|
#socket;
|
|
5
20
|
#bound = false;
|
|
21
|
+
#stunInterceptors = [];
|
|
22
|
+
#turnRoutes = new Map();
|
|
6
23
|
constructor() {
|
|
7
24
|
super();
|
|
8
25
|
// A long-running peer connects to ~9 bootstrap nodes and ~3 TCP
|
|
@@ -24,6 +41,16 @@ export class UdpTransport extends EventEmitter {
|
|
|
24
41
|
const socket = dgram.createSocket("udp4");
|
|
25
42
|
this.#socket = socket;
|
|
26
43
|
socket.on("message", (data, remote) => {
|
|
44
|
+
// STUN/TURN packets get first dibs via registered interceptors
|
|
45
|
+
// (ICE connectivity checks, TURN ALLOCATE responses, and TURN
|
|
46
|
+
// DATA-INDICATION unwrapping). Only if none consume it does the
|
|
47
|
+
// datagram fall through to net_crypto/DHT dispatch.
|
|
48
|
+
if (this.#stunInterceptors.length > 0 && looksLikeStun(data)) {
|
|
49
|
+
for (const intercept of this.#stunInterceptors) {
|
|
50
|
+
if (intercept(data, remote))
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
27
54
|
this.emit("datagram", { data, remote });
|
|
28
55
|
});
|
|
29
56
|
socket.on("error", (error) => this.emit("error", error));
|
|
@@ -77,6 +104,15 @@ export class UdpTransport extends EventEmitter {
|
|
|
77
104
|
if (!socket || !this.#bound) {
|
|
78
105
|
throw new Error("UDP transport is not started");
|
|
79
106
|
}
|
|
107
|
+
// If a TURN route is registered for this destination, tunnel the
|
|
108
|
+
// datagram through the relay instead of sending it raw. net_crypto
|
|
109
|
+
// addresses session.remote = the peer's relay address; we intercept
|
|
110
|
+
// here and wrap in a SEND-INDICATION transparently.
|
|
111
|
+
const route = this.#turnRoutes.get(`${host}:${port}`);
|
|
112
|
+
if (route) {
|
|
113
|
+
route.send(data);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
80
116
|
await new Promise((resolve, reject) => {
|
|
81
117
|
socket.send(data, port, host, (error) => {
|
|
82
118
|
if (error) {
|
|
@@ -87,4 +123,32 @@ export class UdpTransport extends EventEmitter {
|
|
|
87
123
|
});
|
|
88
124
|
});
|
|
89
125
|
}
|
|
126
|
+
/** Direct socket send, bypassing TURN routes. ICE/TURN use this so
|
|
127
|
+
* their own control packets (SEND-INDICATION, binding checks) don't
|
|
128
|
+
* recurse back through the route table. */
|
|
129
|
+
async sendDirect(data, host, port) {
|
|
130
|
+
const socket = this.#socket;
|
|
131
|
+
if (!socket || !this.#bound) {
|
|
132
|
+
throw new Error("UDP transport is not started");
|
|
133
|
+
}
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
socket.send(data, port, host, (error) => (error ? reject(error) : resolve()));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Synchronous fire-and-forget direct send (for hot TURN data path). */
|
|
139
|
+
sendDirectSync(data, host, port) {
|
|
140
|
+
this.#socket?.send(data, port, host);
|
|
141
|
+
}
|
|
142
|
+
addStunInterceptor(fn) {
|
|
143
|
+
this.#stunInterceptors.push(fn);
|
|
144
|
+
}
|
|
145
|
+
removeStunInterceptor(fn) {
|
|
146
|
+
this.#stunInterceptors = this.#stunInterceptors.filter((f) => f !== fn);
|
|
147
|
+
}
|
|
148
|
+
registerTurnRoute(host, port, route) {
|
|
149
|
+
this.#turnRoutes.set(`${host}:${port}`, route);
|
|
150
|
+
}
|
|
151
|
+
unregisterTurnRoute(host, port) {
|
|
152
|
+
this.#turnRoutes.delete(`${host}:${port}`);
|
|
153
|
+
}
|
|
90
154
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier-style TURN credential derivation.
|
|
3
|
+
*
|
|
4
|
+
* Ported verbatim from the C SDK:
|
|
5
|
+
* ~/blockchain/ela/Elastos.NET.Carrier.Native.SDK/src/carrier/carrier_turnserver.c
|
|
6
|
+
*
|
|
7
|
+
* Every Carrier bootnode runs a TURN server on UDP/3478 with this exact
|
|
8
|
+
* authentication scheme. Credentials are derived per request from a
|
|
9
|
+
* freshly generated nonce and the ECDH shared key between our identity
|
|
10
|
+
* keypair and the bootnode's identity public key:
|
|
11
|
+
*
|
|
12
|
+
* shared_key = nacl.box.before(bootnode_pk, our_sk) // X25519
|
|
13
|
+
* nonce = 24 random bytes
|
|
14
|
+
* digest = HMAC-SHA256(shared_key, nonce)
|
|
15
|
+
* username = "{ourUserid}@{base58(nonce)}.auth.tox"
|
|
16
|
+
* password = base58(digest)
|
|
17
|
+
* realm = "elastos.org"
|
|
18
|
+
*
|
|
19
|
+
* The bootnode's TURN process knows its own secret key, can therefore
|
|
20
|
+
* compute the same shared_key, reads `nonce` out of the username, and
|
|
21
|
+
* recomputes the HMAC to verify our password.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CARRIER_TURN_PORT = 3478;
|
|
24
|
+
export declare const CARRIER_TURN_REALM = "elastos.org";
|
|
25
|
+
export declare const CARRIER_TURN_USER_SUFFIX = "auth.tox";
|
|
26
|
+
export interface CarrierTurnCreds {
|
|
27
|
+
host: string;
|
|
28
|
+
port: number;
|
|
29
|
+
realm: string;
|
|
30
|
+
username: string;
|
|
31
|
+
password: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function deriveCarrierTurnCreds(opts: {
|
|
34
|
+
bootnodeHost: string;
|
|
35
|
+
bootnodePublicKey: Uint8Array;
|
|
36
|
+
ourUserid: string;
|
|
37
|
+
ourSecretKey: Uint8Array;
|
|
38
|
+
}): CarrierTurnCreds;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier-style TURN credential derivation.
|
|
3
|
+
*
|
|
4
|
+
* Ported verbatim from the C SDK:
|
|
5
|
+
* ~/blockchain/ela/Elastos.NET.Carrier.Native.SDK/src/carrier/carrier_turnserver.c
|
|
6
|
+
*
|
|
7
|
+
* Every Carrier bootnode runs a TURN server on UDP/3478 with this exact
|
|
8
|
+
* authentication scheme. Credentials are derived per request from a
|
|
9
|
+
* freshly generated nonce and the ECDH shared key between our identity
|
|
10
|
+
* keypair and the bootnode's identity public key:
|
|
11
|
+
*
|
|
12
|
+
* shared_key = nacl.box.before(bootnode_pk, our_sk) // X25519
|
|
13
|
+
* nonce = 24 random bytes
|
|
14
|
+
* digest = HMAC-SHA256(shared_key, nonce)
|
|
15
|
+
* username = "{ourUserid}@{base58(nonce)}.auth.tox"
|
|
16
|
+
* password = base58(digest)
|
|
17
|
+
* realm = "elastos.org"
|
|
18
|
+
*
|
|
19
|
+
* The bootnode's TURN process knows its own secret key, can therefore
|
|
20
|
+
* compute the same shared_key, reads `nonce` out of the username, and
|
|
21
|
+
* recomputes the HMAC to verify our password.
|
|
22
|
+
*/
|
|
23
|
+
import { createHmac } from "crypto";
|
|
24
|
+
import nacl from "tweetnacl";
|
|
25
|
+
import { bytesToBase58 } from "./utils/base58.js";
|
|
26
|
+
import { randomBytes } from "./utils/bytes.js";
|
|
27
|
+
export const CARRIER_TURN_PORT = 3478;
|
|
28
|
+
export const CARRIER_TURN_REALM = "elastos.org";
|
|
29
|
+
export const CARRIER_TURN_USER_SUFFIX = "auth.tox";
|
|
30
|
+
export function deriveCarrierTurnCreds(opts) {
|
|
31
|
+
if (opts.bootnodePublicKey.length !== 32) {
|
|
32
|
+
throw new Error(`bootnodePublicKey must be 32 bytes, got ${opts.bootnodePublicKey.length}`);
|
|
33
|
+
}
|
|
34
|
+
if (opts.ourSecretKey.length !== 32) {
|
|
35
|
+
throw new Error(`ourSecretKey must be 32 bytes, got ${opts.ourSecretKey.length}`);
|
|
36
|
+
}
|
|
37
|
+
const sharedKey = nacl.box.before(opts.bootnodePublicKey, opts.ourSecretKey);
|
|
38
|
+
const nonce = randomBytes(24);
|
|
39
|
+
const digest = createHmac("sha256", Buffer.from(sharedKey)).update(nonce).digest();
|
|
40
|
+
const nonceB58 = bytesToBase58(nonce);
|
|
41
|
+
const passwordB58 = bytesToBase58(Uint8Array.from(digest));
|
|
42
|
+
return {
|
|
43
|
+
host: opts.bootnodeHost,
|
|
44
|
+
port: CARRIER_TURN_PORT,
|
|
45
|
+
realm: CARRIER_TURN_REALM,
|
|
46
|
+
username: `${opts.ourUserid}@${nonceB58}.${CARRIER_TURN_USER_SUFFIX}`,
|
|
47
|
+
password: passwordB58
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/turn.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TURN client (RFC 5766 subset) for the Carrier bootnode TURN
|
|
3
|
+
* servers running on UDP/3478. Covers:
|
|
4
|
+
*
|
|
5
|
+
* - ALLOCATE (with the long-term cred dance: 401 → echo realm/nonce → success)
|
|
6
|
+
* - REFRESH (keeps the allocation alive past `lifetime`)
|
|
7
|
+
* - CREATE-PERMISSION (one per peer address we want to send to)
|
|
8
|
+
* - SEND-INDICATION (wraps app data going out via the relay)
|
|
9
|
+
* - DATA-INDICATION (unwraps app data coming in via the relay)
|
|
10
|
+
*
|
|
11
|
+
* Deliberately skipped to keep this small:
|
|
12
|
+
* - ChannelBind / ChannelData framing — minor bandwidth win, more state
|
|
13
|
+
* - TCP TURN, TURN-over-TLS
|
|
14
|
+
* - DontFragment, Even-port, reservation tokens
|
|
15
|
+
*
|
|
16
|
+
* Owns its UDP socket exclusively while a request is in flight. The
|
|
17
|
+
* caller can multiplex by using a separate TurnClient per allocation
|
|
18
|
+
* (one per bootnode), which is what ICE will do.
|
|
19
|
+
*/
|
|
20
|
+
import type { Socket as DgramSocket } from "dgram";
|
|
21
|
+
import { AddressValue, buildBindingRequest } from "./stun.js";
|
|
22
|
+
import type { CarrierTurnCreds } from "./turn-creds.js";
|
|
23
|
+
export interface TurnAllocation {
|
|
24
|
+
relayedAddress: AddressValue;
|
|
25
|
+
mappedAddress?: AddressValue;
|
|
26
|
+
lifetime: number;
|
|
27
|
+
}
|
|
28
|
+
type DataHandler = (peer: AddressValue, data: Uint8Array) => void;
|
|
29
|
+
export declare class TurnClient {
|
|
30
|
+
#private;
|
|
31
|
+
readonly creds: CarrierTurnCreds;
|
|
32
|
+
constructor(opts: {
|
|
33
|
+
sock: DgramSocket;
|
|
34
|
+
creds: CarrierTurnCreds;
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Perform the long-term-credential ALLOCATE dance. Returns the
|
|
38
|
+
* server-reflexive (mapped) and relay-allocated addresses, plus the
|
|
39
|
+
* allocation lifetime.
|
|
40
|
+
*/
|
|
41
|
+
allocate(): Promise<TurnAllocation>;
|
|
42
|
+
/**
|
|
43
|
+
* Tell the TURN server we want to send to / receive from `peer`.
|
|
44
|
+
* Required before SEND-INDICATION will actually relay.
|
|
45
|
+
*/
|
|
46
|
+
createPermission(peer: AddressValue): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Wrap `data` in a SEND-INDICATION and ship it to the TURN server.
|
|
49
|
+
* Fire-and-forget — TURN indications never get a response.
|
|
50
|
+
*/
|
|
51
|
+
sendTo(peer: AddressValue, data: Uint8Array): void;
|
|
52
|
+
onData(cb: DataHandler): void;
|
|
53
|
+
refresh(): Promise<void>;
|
|
54
|
+
close(): void;
|
|
55
|
+
}
|
|
56
|
+
export { buildBindingRequest };
|