@decentnetwork/peer 0.1.0

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.
Files changed (79) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +97 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +245 -0
  5. package/dist/compat/address.d.ts +13 -0
  6. package/dist/compat/address.js +69 -0
  7. package/dist/compat/bootstrap.d.ts +29 -0
  8. package/dist/compat/bootstrap.js +178 -0
  9. package/dist/compat/dht.d.ts +3 -0
  10. package/dist/compat/dht.js +9 -0
  11. package/dist/compat/express.d.ts +21 -0
  12. package/dist/compat/express.js +263 -0
  13. package/dist/compat/friend.d.ts +4 -0
  14. package/dist/compat/friend.js +12 -0
  15. package/dist/compat/net-crypto.d.ts +84 -0
  16. package/dist/compat/net-crypto.js +278 -0
  17. package/dist/compat/packet.d.ts +55 -0
  18. package/dist/compat/packet.js +154 -0
  19. package/dist/compat/session.d.ts +3 -0
  20. package/dist/compat/session.js +7 -0
  21. package/dist/compat/tcp-relay-pool.d.ts +85 -0
  22. package/dist/compat/tcp-relay-pool.js +342 -0
  23. package/dist/compat/tcp-relay.d.ts +96 -0
  24. package/dist/compat/tcp-relay.js +489 -0
  25. package/dist/compat/text.d.ts +3 -0
  26. package/dist/compat/text.js +8 -0
  27. package/dist/compat/tox-dht-crypto.d.ts +18 -0
  28. package/dist/compat/tox-dht-crypto.js +69 -0
  29. package/dist/compat/tox-onion.d.ts +66 -0
  30. package/dist/compat/tox-onion.js +172 -0
  31. package/dist/crypto/box.d.ts +1 -0
  32. package/dist/crypto/box.js +3 -0
  33. package/dist/crypto/keypair.d.ts +5 -0
  34. package/dist/crypto/keypair.js +37 -0
  35. package/dist/crypto/nonce.d.ts +1 -0
  36. package/dist/crypto/nonce.js +1 -0
  37. package/dist/crypto/sign.d.ts +1 -0
  38. package/dist/crypto/sign.js +3 -0
  39. package/dist/index.d.ts +10 -0
  40. package/dist/index.js +6 -0
  41. package/dist/peer.d.ts +45 -0
  42. package/dist/peer.js +3425 -0
  43. package/dist/runtime/errors.d.ts +3 -0
  44. package/dist/runtime/errors.js +6 -0
  45. package/dist/runtime/events.d.ts +4 -0
  46. package/dist/runtime/events.js +1 -0
  47. package/dist/runtime/lifecycle.d.ts +7 -0
  48. package/dist/runtime/lifecycle.js +12 -0
  49. package/dist/store/config.d.ts +2 -0
  50. package/dist/store/config.js +1 -0
  51. package/dist/store/friends.d.ts +13 -0
  52. package/dist/store/friends.js +1 -0
  53. package/dist/store/state.d.ts +3 -0
  54. package/dist/store/state.js +1 -0
  55. package/dist/transport/socket.d.ts +4 -0
  56. package/dist/transport/socket.js +1 -0
  57. package/dist/transport/tcp.d.ts +3 -0
  58. package/dist/transport/tcp.js +5 -0
  59. package/dist/transport/udp.d.ts +24 -0
  60. package/dist/transport/udp.js +90 -0
  61. package/dist/types/bootstrap.d.ts +2 -0
  62. package/dist/types/bootstrap.js +1 -0
  63. package/dist/types/dht.d.ts +3 -0
  64. package/dist/types/dht.js +1 -0
  65. package/dist/types/friend.d.ts +1 -0
  66. package/dist/types/friend.js +1 -0
  67. package/dist/types/message.d.ts +1 -0
  68. package/dist/types/message.js +1 -0
  69. package/dist/types/peer.d.ts +51 -0
  70. package/dist/types/peer.js +1 -0
  71. package/dist/types/session.d.ts +1 -0
  72. package/dist/types/session.js +1 -0
  73. package/dist/utils/base58.d.ts +2 -0
  74. package/dist/utils/base58.js +51 -0
  75. package/dist/utils/bytes.d.ts +4 -0
  76. package/dist/utils/bytes.js +31 -0
  77. package/docs/INSTALL.md +103 -0
  78. package/docs/USAGE_GUIDE.md +724 -0
  79. package/package.json +77 -0
@@ -0,0 +1,96 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { KeyPair } from "../crypto/keypair.js";
3
+ export declare const TCP_PACKET_ROUTING_REQUEST = 0;
4
+ export declare const TCP_PACKET_ROUTING_RESPONSE = 1;
5
+ export declare const TCP_PACKET_CONNECTION_NOTIFICATION = 2;
6
+ export declare const TCP_PACKET_DISCONNECT_NOTIFICATION = 3;
7
+ export declare const TCP_PACKET_PING = 4;
8
+ export declare const TCP_PACKET_PONG = 5;
9
+ export declare const TCP_PACKET_OOB_SEND = 6;
10
+ export declare const TCP_PACKET_OOB_RECV = 7;
11
+ export declare const TCP_PACKET_ONION_REQUEST = 8;
12
+ export declare const TCP_PACKET_ONION_RESPONSE = 9;
13
+ export type TcpRelayState = "disconnected" | "connecting" | "handshake-sent" | "connected" | "closed";
14
+ export type TcpRelayOptions = {
15
+ /** Hostname or IPv4 of the relay server. */
16
+ host: string;
17
+ /** TCP port. Carrier bootstraps typically run on 443, 3389, or 33445. */
18
+ port: number;
19
+ /** Server's DHT public key (the same `pk` field used for the UDP DHT). */
20
+ serverPublicKey: Uint8Array;
21
+ /** Our DHT keypair (= our real keypair in this codebase). */
22
+ selfKeyPair: KeyPair;
23
+ /** Optional debug label (operator-supplied) for log lines. */
24
+ label?: string;
25
+ };
26
+ /** Inbound traffic surfaced to the owner. */
27
+ export type TcpRelayEvents = {
28
+ /** Handshake completed; the relay is now usable. */
29
+ open: () => void;
30
+ /** Connection terminated, either by us or by remote. */
31
+ close: (reason: string) => void;
32
+ /**
33
+ * The relay accepted our ROUTING_REQUEST and assigned a connection_id.
34
+ * Surfaced for diagnostics; for actual messaging use the `friendOnline`
35
+ * / `peerData` events which key by the friend's real public key.
36
+ */
37
+ routing: (connectionId: number, friendKey: Uint8Array) => void;
38
+ /**
39
+ * Friend (identified by their real pubkey) is now actively connected
40
+ * to this relay and routing is up. Both sides asked the relay about
41
+ * each other; we and they can now exchange DATA.
42
+ */
43
+ friendOnline: (friendPublicKey: Uint8Array) => void;
44
+ /** Friend dropped from the relay. */
45
+ friendOffline: (friendPublicKey: Uint8Array) => void;
46
+ /**
47
+ * Inbound DATA from a connected friend, already mapped by the client's
48
+ * internal connection_id → pubkey table. Treat the payload as you
49
+ * would a UDP datagram from that friend (cookie request / response /
50
+ * handshake / crypto_data, with the same iveg magic prefix as UDP).
51
+ */
52
+ peerData: (friendPublicKey: Uint8Array, payload: Uint8Array) => void;
53
+ /** Inbound OOB_RECV — friend's pubkey + payload (used for friend requests). */
54
+ oob: (senderPublicKey: Uint8Array, payload: Uint8Array) => void;
55
+ /** Pong (response to our ping). */
56
+ pong: (pingId: bigint) => void;
57
+ };
58
+ export declare class TcpRelayClient extends EventEmitter {
59
+ #private;
60
+ constructor(opts: TcpRelayOptions);
61
+ state(): TcpRelayState;
62
+ /**
63
+ * Open the TCP connection and perform the handshake. Resolves once the
64
+ * relay is in "connected" state (handshake response decrypted, session
65
+ * keys derived). Rejects on any failure during connect or handshake.
66
+ */
67
+ connect(timeoutMs?: number): Promise<void>;
68
+ /**
69
+ * Send ROUTING_REQUEST so the relay starts watching for `friendPublicKey`.
70
+ * Idempotent — calling twice with the same key is a no-op (the relay
71
+ * would otherwise allocate a duplicate cid). Use `forgetRoute` to
72
+ * forcibly re-request.
73
+ */
74
+ requestRoute(friendPublicKey: Uint8Array): boolean;
75
+ /** Returns true if we've received a CONNECTION_NOTIFICATION for this friend. */
76
+ hasFriendOnline(friendPublicKey: Uint8Array): boolean;
77
+ /** Returns true if we've sent a ROUTING_REQUEST for this friend (regardless of online state). */
78
+ hasRequestedRoute(friendPublicKey: Uint8Array): boolean;
79
+ /**
80
+ * Send a DATA payload to `friendPublicKey` over this relay. Returns
81
+ * false if the friend hasn't been routed (no ROUTING_RESPONSE yet) or
82
+ * if the relay says they're offline. Caller should treat false as
83
+ * "try another transport".
84
+ */
85
+ sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): boolean;
86
+ /** Send a PING; relay echoes it as PONG. ping_id is opaque 8 bytes. */
87
+ sendPing(): boolean;
88
+ /** Send DATA payload to the friend at `connectionId`. */
89
+ sendData(connectionId: number, payload: Uint8Array): boolean;
90
+ /** Send OOB_SEND (used for delivering friend requests via TCP relay). */
91
+ sendOob(receiverPublicKey: Uint8Array, payload: Uint8Array): boolean;
92
+ close(reason?: string): void;
93
+ on<E extends keyof TcpRelayEvents>(event: E, listener: TcpRelayEvents[E]): this;
94
+ once<E extends keyof TcpRelayEvents>(event: E, listener: TcpRelayEvents[E]): this;
95
+ off<E extends keyof TcpRelayEvents>(event: E, listener: TcpRelayEvents[E]): this;
96
+ }
@@ -0,0 +1,489 @@
1
+ // TCP relay client — port of toxcore's TCP_client.c.
2
+ //
3
+ // Carrier (= unmodified toxcore) has two transports for net_crypto traffic
4
+ // between peers:
5
+ //
6
+ // 1. UDP/onion direct — fast, but blocked by hostile NATs and rate-
7
+ // limited by some ISPs.
8
+ // 2. TCP relay — every Carrier bootstrap also runs a TCP relay server
9
+ // on ports 443/3389/33445. Each peer maintains 1-3 persistent TCP
10
+ // connections to relay servers. When two peers want to talk and at
11
+ // least one of them can't reach the other via UDP, they exchange
12
+ // packets through a relay they both happen to be connected to.
13
+ //
14
+ // iOS Beagle uses TCP relays as its *primary* path (its DHT-PK announces
15
+ // only ever advertise TCP relay endpoints — `0x82 = TCP_FAMILY_IPV4` in
16
+ // the packed-nodes extras). Without a TCP client on our side, an iPad/
17
+ // iPhone Beagle peer will mark us offline forever even though everything
18
+ // else is correctly configured.
19
+ //
20
+ // This module implements the client half of the toxcore TCP relay
21
+ // protocol. The server side (TCP_server.c) is hosted by Carrier
22
+ // bootstrap operators; we never run a server. Reference:
23
+ //
24
+ // toxcore/TCP_client.{c,h} — what this is a port of
25
+ // toxcore/TCP_connection.{c,h} — multi-relay coordinator (Phase 6)
26
+ // toxcore/TCP_server.h — packet-type constants
27
+ //
28
+ // Threading / lifecycle: one TcpRelayClient = one persistent TCP
29
+ // connection to one relay. Open it once at peer.start, keep it alive
30
+ // with PING every ~10s, reconnect if the socket drops. The Peer class
31
+ // will own a small pool (Phase 6).
32
+ import nacl from "tweetnacl";
33
+ import { connect as netConnect } from "node:net";
34
+ import { EventEmitter } from "node:events";
35
+ import { concatBytes, randomBytes } from "../utils/bytes.js";
36
+ import { incrementNonce } from "./net-crypto.js";
37
+ const KEY_SIZE = 32;
38
+ const NONCE_SIZE = 24;
39
+ const MAC_SIZE = 16;
40
+ /** TCP_HANDSHAKE_PLAIN_SIZE in toxcore — temp pubkey + initial nonce. */
41
+ const TCP_HANDSHAKE_PLAIN_SIZE = KEY_SIZE + NONCE_SIZE;
42
+ /** Outbound handshake: self_dht_pk(32) + nonce(24) + box(plain)(KEY_SIZE+NONCE_SIZE+MAC_SIZE = 72) = 128. */
43
+ const TCP_CLIENT_HANDSHAKE_SIZE = KEY_SIZE + NONCE_SIZE + TCP_HANDSHAKE_PLAIN_SIZE + MAC_SIZE;
44
+ /** Inbound handshake response: nonce(24) + box(plain)(KEY_SIZE+NONCE_SIZE+MAC_SIZE = 72) = 96. */
45
+ const TCP_SERVER_HANDSHAKE_SIZE = NONCE_SIZE + TCP_HANDSHAKE_PLAIN_SIZE + MAC_SIZE;
46
+ // TCP_PACKET_* constants — see toxcore/TCP_server.h. Packet type 16+ is
47
+ // data forwarded to/from a routed friend (the type byte is the
48
+ // connection_id; payload is whatever the friend sent us).
49
+ export const TCP_PACKET_ROUTING_REQUEST = 0;
50
+ export const TCP_PACKET_ROUTING_RESPONSE = 1;
51
+ export const TCP_PACKET_CONNECTION_NOTIFICATION = 2;
52
+ export const TCP_PACKET_DISCONNECT_NOTIFICATION = 3;
53
+ export const TCP_PACKET_PING = 4;
54
+ export const TCP_PACKET_PONG = 5;
55
+ export const TCP_PACKET_OOB_SEND = 6;
56
+ export const TCP_PACKET_OOB_RECV = 7;
57
+ export const TCP_PACKET_ONION_REQUEST = 8;
58
+ export const TCP_PACKET_ONION_RESPONSE = 9;
59
+ /** Max payload of a single framed packet (toxcore: MAX_PACKET_SIZE = 2048). */
60
+ const MAX_PACKET_SIZE = 2048;
61
+ /**
62
+ * Carrier-only TCP frame magic. Handshake packets (in either direction)
63
+ * are sent raw, but every encrypted frame *after* the handshake is
64
+ * prefixed with these 4 bytes. The server (TCP_server.c::read_TCP_length)
65
+ * reads the magic before reading the 2-byte length and rejects frames
66
+ * without it. Same convention as the iveg prefix on UDP datagrams,
67
+ * applied at the TCP layer in TCP_client.c::write_packet_TCP_secure_connection.
68
+ */
69
+ const CARRIER_TCP_MAGIC = Uint8Array.of(0x69, 0x76, 0x65, 0x67);
70
+ export class TcpRelayClient extends EventEmitter {
71
+ #opts;
72
+ #socket;
73
+ #state = "disconnected";
74
+ #debug = process.env.DECENT_DEBUG === "1";
75
+ // Crypto state derived during handshake.
76
+ #tempKeyPair;
77
+ #sentNonce; // TX nonce — increments per outbound encrypted frame
78
+ #recvNonce; // RX nonce — increments per inbound encrypted frame
79
+ #sessionKey; // box.before(server_temp_pk, our_temp_sk), used as secretbox key
80
+ // RX buffer for the framed encrypted stream.
81
+ #rxBuf = Buffer.alloc(0);
82
+ // Routing state — the relay assigns one connection_id per friend we
83
+ // requested routing for, in [16, 255]. Map both ways so we can:
84
+ // - resolve inbound DATA(connection_id) to friend pubkey
85
+ // - resolve outbound sendToFriend(pubkey) to a connection_id
86
+ #cidByFriend = new Map(); // hex(friend pubkey) -> cid
87
+ #friendByCid = new Map(); // cid -> raw friend pubkey
88
+ // Friends whose routing we've requested but the relay hasn't yet
89
+ // sent CONNECTION_NOTIFICATION for. We dedupe re-requests via this set.
90
+ #routesRequested = new Set(); // hex(friend pubkey)
91
+ constructor(opts) {
92
+ super();
93
+ if (opts.serverPublicKey.length !== KEY_SIZE) {
94
+ throw new Error(`relay server public key must be ${KEY_SIZE} bytes`);
95
+ }
96
+ this.#opts = opts;
97
+ }
98
+ state() {
99
+ return this.#state;
100
+ }
101
+ /**
102
+ * Open the TCP connection and perform the handshake. Resolves once the
103
+ * relay is in "connected" state (handshake response decrypted, session
104
+ * keys derived). Rejects on any failure during connect or handshake.
105
+ */
106
+ async connect(timeoutMs = 10_000) {
107
+ if (this.#state !== "disconnected" && this.#state !== "closed") {
108
+ throw new Error(`relay already ${this.#state}`);
109
+ }
110
+ this.#state = "connecting";
111
+ this.#log(`connecting to ${this.#opts.host}:${this.#opts.port}`);
112
+ return new Promise((resolve, reject) => {
113
+ const sock = netConnect({ host: this.#opts.host, port: this.#opts.port });
114
+ this.#socket = sock;
115
+ let settled = false;
116
+ const fail = (reason) => {
117
+ if (settled)
118
+ return;
119
+ settled = true;
120
+ this.#close(reason);
121
+ reject(new Error(reason));
122
+ };
123
+ const ok = () => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ resolve();
128
+ };
129
+ const connectTimer = setTimeout(() => fail(`connect timeout after ${timeoutMs}ms`), timeoutMs);
130
+ sock.once("error", (err) => {
131
+ clearTimeout(connectTimer);
132
+ fail(`socket error: ${err.message}`);
133
+ });
134
+ sock.once("close", () => {
135
+ clearTimeout(connectTimer);
136
+ if (!settled)
137
+ fail("socket closed before handshake completed");
138
+ else
139
+ this.#close("socket closed");
140
+ });
141
+ sock.once("connect", () => {
142
+ clearTimeout(connectTimer);
143
+ try {
144
+ const handshake = this.#buildHandshake();
145
+ sock.write(handshake);
146
+ this.#state = "handshake-sent";
147
+ this.#log(`handshake sent (${handshake.length} bytes), waiting for server response`);
148
+ }
149
+ catch (err) {
150
+ fail(`handshake build failed: ${err.message}`);
151
+ return;
152
+ }
153
+ // Wait specifically for the 96-byte handshake response.
154
+ let handshakeBuf = Buffer.alloc(0);
155
+ const onHandshakeData = (chunk) => {
156
+ handshakeBuf = Buffer.concat([handshakeBuf, chunk]);
157
+ if (handshakeBuf.length < TCP_SERVER_HANDSHAKE_SIZE) {
158
+ return;
159
+ }
160
+ const hs = handshakeBuf.subarray(0, TCP_SERVER_HANDSHAKE_SIZE);
161
+ const remainder = handshakeBuf.subarray(TCP_SERVER_HANDSHAKE_SIZE);
162
+ sock.removeListener("data", onHandshakeData);
163
+ try {
164
+ this.#processHandshakeResponse(hs);
165
+ }
166
+ catch (err) {
167
+ fail(`handshake response decrypt failed: ${err.message}`);
168
+ return;
169
+ }
170
+ this.#state = "connected";
171
+ this.#log(`handshake complete; session ready`);
172
+ // Switch to the framed-data parser for everything after.
173
+ sock.on("data", (next) => this.#onData(next));
174
+ // If the handshake response chunk also carried framed data, feed it.
175
+ if (remainder.length > 0) {
176
+ this.#onData(remainder);
177
+ }
178
+ this.emit("open");
179
+ ok();
180
+ };
181
+ sock.on("data", onHandshakeData);
182
+ });
183
+ });
184
+ }
185
+ /**
186
+ * Construct the 128-byte client handshake (toxcore TCP_client.c::generate_handshake).
187
+ * Layout:
188
+ * [self_dht_pubkey (32)] [nonce (24)] [box(plain) (72)]
189
+ * where plain = [temp_pubkey (32)] [sent_nonce (24)] encrypted with
190
+ * shared(serverPublicKey, selfSecretKey) using the random nonce.
191
+ */
192
+ #buildHandshake() {
193
+ this.#tempKeyPair = (() => {
194
+ const kp = nacl.box.keyPair();
195
+ return { publicKey: kp.publicKey, secretKey: kp.secretKey };
196
+ })();
197
+ this.#sentNonce = randomBytes(NONCE_SIZE);
198
+ const plain = concatBytes([this.#tempKeyPair.publicKey, this.#sentNonce]);
199
+ const handshakeNonce = randomBytes(NONCE_SIZE);
200
+ const sharedKey = nacl.box.before(this.#opts.serverPublicKey, this.#opts.selfKeyPair.secretKey);
201
+ const encrypted = nacl.box.after(plain, handshakeNonce, sharedKey);
202
+ return concatBytes([this.#opts.selfKeyPair.publicKey, handshakeNonce, encrypted]);
203
+ }
204
+ /**
205
+ * Decrypt the 96-byte server handshake response (toxcore TCP_client.c::handle_handshake).
206
+ * Layout:
207
+ * [nonce (24)] [box(plain) (72)]
208
+ * plain = [server_temp_pubkey (32)] [recv_nonce (24)]
209
+ * Sets #recvNonce and derives the session key for the encrypted stream.
210
+ */
211
+ #processHandshakeResponse(hs) {
212
+ if (hs.length !== TCP_SERVER_HANDSHAKE_SIZE) {
213
+ throw new Error(`handshake response must be ${TCP_SERVER_HANDSHAKE_SIZE} bytes`);
214
+ }
215
+ const nonce = hs.subarray(0, NONCE_SIZE);
216
+ const cipher = hs.subarray(NONCE_SIZE);
217
+ const sharedKey = nacl.box.before(this.#opts.serverPublicKey, this.#opts.selfKeyPair.secretKey);
218
+ const plain = nacl.box.open.after(cipher, nonce, sharedKey);
219
+ if (!plain || plain.length !== TCP_HANDSHAKE_PLAIN_SIZE) {
220
+ throw new Error("handshake response decrypt failed");
221
+ }
222
+ const serverTempPub = plain.subarray(0, KEY_SIZE);
223
+ const recvNonce = plain.subarray(KEY_SIZE, KEY_SIZE + NONCE_SIZE);
224
+ if (!this.#tempKeyPair) {
225
+ throw new Error("no temp keypair set");
226
+ }
227
+ // Derive the session key used for all subsequent secretbox frames.
228
+ this.#sessionKey = nacl.box.before(serverTempPub, this.#tempKeyPair.secretKey);
229
+ this.#recvNonce = new Uint8Array(recvNonce);
230
+ // Wipe the temp secret key — toxcore does crypto_memzero here.
231
+ this.#tempKeyPair.secretKey.fill(0);
232
+ }
233
+ /**
234
+ * Frame parser for everything after the handshake. Each frame is
235
+ * [4-byte 'iveg' magic][2-byte BE length][secretbox-encrypted body]
236
+ * Bodies may straddle TCP read chunks — buffer until a complete frame
237
+ * is available, then dispatch.
238
+ */
239
+ #onData(chunk) {
240
+ this.#rxBuf = this.#rxBuf.length === 0 ? chunk : Buffer.concat([this.#rxBuf, chunk]);
241
+ while (this.#rxBuf.length >= 4 + 2) {
242
+ // Verify the iveg prefix before reading the length.
243
+ if (this.#rxBuf[0] !== CARRIER_TCP_MAGIC[0] ||
244
+ this.#rxBuf[1] !== CARRIER_TCP_MAGIC[1] ||
245
+ this.#rxBuf[2] !== CARRIER_TCP_MAGIC[2] ||
246
+ this.#rxBuf[3] !== CARRIER_TCP_MAGIC[3]) {
247
+ this.#close("missing/invalid iveg magic on TCP frame");
248
+ return;
249
+ }
250
+ const frameLen = this.#rxBuf.readUInt16BE(4);
251
+ if (frameLen === 0 || frameLen > MAX_PACKET_SIZE) {
252
+ this.#close(`invalid frame length ${frameLen}`);
253
+ return;
254
+ }
255
+ if (this.#rxBuf.length < 4 + 2 + frameLen) {
256
+ return; // wait for more data
257
+ }
258
+ const cipher = this.#rxBuf.subarray(4 + 2, 4 + 2 + frameLen);
259
+ this.#rxBuf = this.#rxBuf.subarray(4 + 2 + frameLen);
260
+ const plain = this.#decryptFrame(cipher);
261
+ if (!plain) {
262
+ this.#close("frame decrypt failed");
263
+ return;
264
+ }
265
+ this.#dispatch(plain);
266
+ }
267
+ }
268
+ /** Decrypt one frame body using sessionKey + #recvNonce, then increment nonce. */
269
+ #decryptFrame(cipher) {
270
+ if (!this.#sessionKey || !this.#recvNonce)
271
+ return undefined;
272
+ const opened = nacl.secretbox.open(cipher, this.#recvNonce, this.#sessionKey);
273
+ if (!opened)
274
+ return undefined;
275
+ incrementNonce(this.#recvNonce);
276
+ return opened;
277
+ }
278
+ /** Encrypt a payload and write it as one framed packet. */
279
+ #sendFrame(payload) {
280
+ if (this.#state !== "connected" || !this.#sessionKey || !this.#sentNonce || !this.#socket) {
281
+ return false;
282
+ }
283
+ if (payload.length === 0 || payload.length > MAX_PACKET_SIZE - MAC_SIZE) {
284
+ return false;
285
+ }
286
+ const cipher = nacl.secretbox(payload, this.#sentNonce, this.#sessionKey);
287
+ incrementNonce(this.#sentNonce);
288
+ // Wire layout: [4-byte iveg][2-byte BE length][cipher].
289
+ const frame = Buffer.alloc(4 + 2 + cipher.length);
290
+ frame.set(CARRIER_TCP_MAGIC, 0);
291
+ frame.writeUInt16BE(cipher.length, 4);
292
+ frame.set(cipher, 4 + 2);
293
+ return this.#socket.write(frame);
294
+ }
295
+ /**
296
+ * Dispatch a decrypted packet by leading byte. The toxcore wire format
297
+ * uses the first byte as the packet type; values 0-9 are control
298
+ * packets (see TCP_PACKET_* constants), and values 16+ are data
299
+ * forwarded between routed peers — the byte itself is the connection_id.
300
+ */
301
+ #dispatch(plain) {
302
+ if (plain.length === 0)
303
+ return;
304
+ const type = plain[0];
305
+ const body = plain.subarray(1);
306
+ switch (type) {
307
+ case TCP_PACKET_ROUTING_RESPONSE: {
308
+ // [rpid (1)][friend_pubkey (32)] — rpid is the connection_id, or 0
309
+ // if the relay refused to allocate one (e.g. table full).
310
+ if (body.length < 1 + KEY_SIZE)
311
+ return;
312
+ const connectionId = body[0];
313
+ const friendKey = body.subarray(1, 1 + KEY_SIZE);
314
+ if (connectionId !== 0) {
315
+ const friendHex = Buffer.from(friendKey).toString("hex");
316
+ this.#cidByFriend.set(friendHex, connectionId);
317
+ this.#friendByCid.set(connectionId, new Uint8Array(friendKey));
318
+ }
319
+ this.emit("routing", connectionId, friendKey);
320
+ return;
321
+ }
322
+ case TCP_PACKET_CONNECTION_NOTIFICATION: {
323
+ if (body.length < 1)
324
+ return;
325
+ const cid = body[0];
326
+ const friendKey = this.#friendByCid.get(cid);
327
+ if (friendKey) {
328
+ this.emit("friendOnline", friendKey);
329
+ }
330
+ return;
331
+ }
332
+ case TCP_PACKET_DISCONNECT_NOTIFICATION: {
333
+ if (body.length < 1)
334
+ return;
335
+ const cid = body[0];
336
+ const friendKey = this.#friendByCid.get(cid);
337
+ if (friendKey) {
338
+ this.emit("friendOffline", friendKey);
339
+ }
340
+ return;
341
+ }
342
+ case TCP_PACKET_PING: {
343
+ // Server-initiated ping — respond with PONG echoing the ping_id.
344
+ if (body.length < 8)
345
+ return;
346
+ const pingId = body.subarray(0, 8);
347
+ this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_PONG), pingId]));
348
+ return;
349
+ }
350
+ case TCP_PACKET_PONG: {
351
+ if (body.length < 8)
352
+ return;
353
+ let v = 0n;
354
+ for (let i = 0; i < 8; i++)
355
+ v = (v << 8n) | BigInt(body[i]);
356
+ this.emit("pong", v);
357
+ return;
358
+ }
359
+ case TCP_PACKET_OOB_RECV: {
360
+ // [sender_pubkey (32)][payload]
361
+ if (body.length < KEY_SIZE)
362
+ return;
363
+ const sender = body.subarray(0, KEY_SIZE);
364
+ const payload = body.subarray(KEY_SIZE);
365
+ this.emit("oob", sender, payload);
366
+ return;
367
+ }
368
+ default: {
369
+ if (type >= 16) {
370
+ // DATA forwarded by relay from a connected peer. Resolve
371
+ // connection_id → friend pubkey and surface as peerData. If we
372
+ // somehow haven't recorded the cid (race with a stale relay
373
+ // entry), drop silently — owner can't act on a connection_id
374
+ // they don't have a key for.
375
+ const friendKey = this.#friendByCid.get(type);
376
+ if (friendKey) {
377
+ this.emit("peerData", friendKey, body);
378
+ }
379
+ return;
380
+ }
381
+ // Other types (ROUTING_REQUEST, OOB_SEND, ONION_REQUEST/RESPONSE)
382
+ // are server-bound or out of scope for this phase.
383
+ }
384
+ }
385
+ }
386
+ /**
387
+ * Send ROUTING_REQUEST so the relay starts watching for `friendPublicKey`.
388
+ * Idempotent — calling twice with the same key is a no-op (the relay
389
+ * would otherwise allocate a duplicate cid). Use `forgetRoute` to
390
+ * forcibly re-request.
391
+ */
392
+ requestRoute(friendPublicKey) {
393
+ if (friendPublicKey.length !== KEY_SIZE)
394
+ return false;
395
+ const friendHex = Buffer.from(friendPublicKey).toString("hex");
396
+ if (this.#routesRequested.has(friendHex) || this.#cidByFriend.has(friendHex)) {
397
+ return true; // already requested or established
398
+ }
399
+ this.#routesRequested.add(friendHex);
400
+ return this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_ROUTING_REQUEST), friendPublicKey]));
401
+ }
402
+ /** Returns true if we've received a CONNECTION_NOTIFICATION for this friend. */
403
+ hasFriendOnline(friendPublicKey) {
404
+ if (friendPublicKey.length !== KEY_SIZE)
405
+ return false;
406
+ return this.#cidByFriend.has(Buffer.from(friendPublicKey).toString("hex"));
407
+ }
408
+ /** Returns true if we've sent a ROUTING_REQUEST for this friend (regardless of online state). */
409
+ hasRequestedRoute(friendPublicKey) {
410
+ if (friendPublicKey.length !== KEY_SIZE)
411
+ return false;
412
+ const friendHex = Buffer.from(friendPublicKey).toString("hex");
413
+ return this.#routesRequested.has(friendHex) || this.#cidByFriend.has(friendHex);
414
+ }
415
+ /**
416
+ * Send a DATA payload to `friendPublicKey` over this relay. Returns
417
+ * false if the friend hasn't been routed (no ROUTING_RESPONSE yet) or
418
+ * if the relay says they're offline. Caller should treat false as
419
+ * "try another transport".
420
+ */
421
+ sendToFriend(friendPublicKey, payload) {
422
+ if (friendPublicKey.length !== KEY_SIZE)
423
+ return false;
424
+ const cid = this.#cidByFriend.get(Buffer.from(friendPublicKey).toString("hex"));
425
+ if (cid === undefined)
426
+ return false;
427
+ return this.sendData(cid, payload);
428
+ }
429
+ /** Send a PING; relay echoes it as PONG. ping_id is opaque 8 bytes. */
430
+ sendPing() {
431
+ const pingId = randomBytes(8);
432
+ return this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_PING), pingId]));
433
+ }
434
+ /** Send DATA payload to the friend at `connectionId`. */
435
+ sendData(connectionId, payload) {
436
+ if (connectionId < 16 || connectionId > 0xff)
437
+ return false;
438
+ if (payload.length === 0)
439
+ return false;
440
+ return this.#sendFrame(concatBytes([Uint8Array.of(connectionId), payload]));
441
+ }
442
+ /** Send OOB_SEND (used for delivering friend requests via TCP relay). */
443
+ sendOob(receiverPublicKey, payload) {
444
+ if (receiverPublicKey.length !== KEY_SIZE)
445
+ return false;
446
+ return this.#sendFrame(concatBytes([Uint8Array.of(TCP_PACKET_OOB_SEND), receiverPublicKey, payload]));
447
+ }
448
+ close(reason = "explicit close") {
449
+ this.#close(reason);
450
+ }
451
+ #close(reason) {
452
+ if (this.#state === "closed")
453
+ return;
454
+ this.#state = "closed";
455
+ this.#log(`closing: ${reason}`);
456
+ if (this.#socket) {
457
+ try {
458
+ this.#socket.destroy();
459
+ }
460
+ catch { /* ignore */ }
461
+ this.#socket = undefined;
462
+ }
463
+ if (this.#sentNonce)
464
+ this.#sentNonce.fill(0);
465
+ if (this.#sessionKey)
466
+ this.#sessionKey.fill(0);
467
+ this.#sentNonce = undefined;
468
+ this.#recvNonce = undefined;
469
+ this.#sessionKey = undefined;
470
+ this.#tempKeyPair = undefined;
471
+ this.emit("close", reason);
472
+ }
473
+ #log(message) {
474
+ if (!this.#debug)
475
+ return;
476
+ const label = this.#opts.label ? `:${this.#opts.label}` : "";
477
+ console.log(`[peer-debug:tcp-relay${label}] ${this.#opts.host}:${this.#opts.port} ${message}`);
478
+ }
479
+ // Strongly-typed event helpers so callers get autocompletion.
480
+ on(event, listener) {
481
+ return super.on(event, listener);
482
+ }
483
+ once(event, listener) {
484
+ return super.once(event, listener);
485
+ }
486
+ off(event, listener) {
487
+ return super.off(event, listener);
488
+ }
489
+ }
@@ -0,0 +1,3 @@
1
+ export declare class LegacyTextClient {
2
+ send(pubkey: string, text: string): Promise<never>;
3
+ }
@@ -0,0 +1,8 @@
1
+ import { LegacyProtocolNotImplementedError } from "../runtime/errors.js";
2
+ export class LegacyTextClient {
3
+ async send(pubkey, text) {
4
+ void pubkey;
5
+ void text;
6
+ throw new LegacyProtocolNotImplementedError("Legacy online text");
7
+ }
8
+ }
@@ -0,0 +1,18 @@
1
+ import type { KeyPair } from "../crypto/keypair.js";
2
+ export declare const NET_PACKET_CRYPTO = 32;
3
+ export declare const CRYPTO_PACKET_FRIEND_REQ = 32;
4
+ export declare const CRYPTO_PACKET_DHTPK = 156;
5
+ export declare const CRYPTO_PACKET_NAT_PING = 254;
6
+ export type ToxDhtCryptoRequest = {
7
+ receiverPublicKey: Uint8Array;
8
+ senderPublicKey: Uint8Array;
9
+ requestId: number;
10
+ data: Uint8Array;
11
+ };
12
+ export declare function createToxDhtCryptoRequest(opts: {
13
+ sender: KeyPair;
14
+ receiverPublicKey: Uint8Array;
15
+ requestId: number;
16
+ data: Uint8Array;
17
+ }): Uint8Array;
18
+ export declare function openToxDhtCryptoRequest(packet: Uint8Array, self: KeyPair): ToxDhtCryptoRequest | undefined;