@decentnetwork/peer 0.1.17 → 0.1.19

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/stun.d.ts ADDED
@@ -0,0 +1,117 @@
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
+ export declare const STUN_MAGIC_COOKIE = 554869826;
34
+ export declare const STUN_BINDING_REQUEST = 1;
35
+ export declare const STUN_BINDING_SUCCESS = 257;
36
+ export declare const STUN_BINDING_ERROR = 273;
37
+ export declare const TURN_ALLOCATE_REQUEST = 3;
38
+ export declare const TURN_ALLOCATE_SUCCESS = 259;
39
+ export declare const TURN_ALLOCATE_ERROR = 275;
40
+ export declare const TURN_REFRESH_REQUEST = 4;
41
+ export declare const TURN_REFRESH_SUCCESS = 260;
42
+ export declare const TURN_CREATE_PERMISSION_REQUEST = 8;
43
+ export declare const TURN_CREATE_PERMISSION_SUCCESS = 264;
44
+ export declare const TURN_SEND_INDICATION = 22;
45
+ export declare const TURN_DATA_INDICATION = 23;
46
+ export declare const STUN_ATTR_MAPPED_ADDRESS = 1;
47
+ export declare const STUN_ATTR_USERNAME = 6;
48
+ export declare const STUN_ATTR_MESSAGE_INTEGRITY = 8;
49
+ export declare const STUN_ATTR_ERROR_CODE = 9;
50
+ export declare const STUN_ATTR_REALM = 20;
51
+ export declare const STUN_ATTR_NONCE = 21;
52
+ export declare const STUN_ATTR_XOR_MAPPED_ADDRESS = 32;
53
+ export declare const STUN_ATTR_SOFTWARE = 32802;
54
+ export declare const STUN_ATTR_FINGERPRINT = 32808;
55
+ export declare const STUN_ATTR_CHANNEL_NUMBER = 12;
56
+ export declare const STUN_ATTR_LIFETIME = 13;
57
+ export declare const STUN_ATTR_XOR_PEER_ADDRESS = 18;
58
+ export declare const STUN_ATTR_DATA = 19;
59
+ export declare const STUN_ATTR_XOR_RELAYED_ADDRESS = 22;
60
+ export declare const STUN_ATTR_REQUESTED_TRANSPORT = 25;
61
+ export declare const STUN_ATTR_DONT_FRAGMENT = 26;
62
+ export interface StunAttribute {
63
+ type: number;
64
+ value: Uint8Array;
65
+ }
66
+ export interface StunMessage {
67
+ type: number;
68
+ transactionId: Uint8Array;
69
+ attributes: StunAttribute[];
70
+ }
71
+ export interface AddressValue {
72
+ family: 4 | 6;
73
+ address: string;
74
+ port: number;
75
+ }
76
+ export declare function newTransactionId(): Uint8Array;
77
+ /**
78
+ * Encode a STUN message. If `integrityKey` is provided, a
79
+ * MESSAGE-INTEGRITY attribute (HMAC-SHA1) is appended. If `fingerprint`
80
+ * is true, a FINGERPRINT (CRC32 XOR 0x5354554e) is appended last.
81
+ *
82
+ * The order matters because MESSAGE-INTEGRITY must be computed over
83
+ * the message *as if* it were the last attribute (with length already
84
+ * adjusted), and FINGERPRINT must be computed over the message
85
+ * including MESSAGE-INTEGRITY.
86
+ */
87
+ export declare function encodeStun(message: StunMessage, opts?: {
88
+ integrityKey?: Uint8Array;
89
+ fingerprint?: boolean;
90
+ }): Uint8Array;
91
+ export declare function decodeStun(data: Uint8Array): StunMessage | undefined;
92
+ export declare function findAttr(message: StunMessage, type: number): Uint8Array | undefined;
93
+ /**
94
+ * Decode XOR-MAPPED-ADDRESS (RFC 5389 §15.2). The high bits of the
95
+ * port and the address are XORed with the magic cookie + transactionId.
96
+ */
97
+ export declare function decodeXorMappedAddress(value: Uint8Array, transactionId: Uint8Array): AddressValue | undefined;
98
+ /** Encode XOR-MAPPED-ADDRESS for outgoing STUN responses (TURN, conn check). */
99
+ export declare function encodeXorMappedAddress(addr: AddressValue, transactionId: Uint8Array): Uint8Array;
100
+ export declare function encodeErrorCode(code: number, reason: string): Uint8Array;
101
+ export declare function decodeErrorCode(value: Uint8Array): {
102
+ code: number;
103
+ reason: string;
104
+ } | undefined;
105
+ /**
106
+ * Long-term-credential integrity key: MD5("username:realm:password").
107
+ * RFC 5389 §15.4. coturn uses this.
108
+ */
109
+ export declare function longTermIntegrityKey(username: string, realm: string, password: string): Uint8Array;
110
+ /** Short-term-credential integrity key: just the password as UTF-8. */
111
+ export declare function shortTermIntegrityKey(password: string): Uint8Array;
112
+ export declare function buildBindingRequest(transactionId?: Uint8Array): Uint8Array;
113
+ export declare function buildBindingRequestWithIntegrity(opts: {
114
+ username: string;
115
+ password: string;
116
+ transactionId?: Uint8Array;
117
+ }): Uint8Array;
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
+ }
@@ -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
  }
@@ -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
+ }