@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.
- package/LICENSE +31 -0
- package/README.md +97 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +245 -0
- package/dist/compat/address.d.ts +13 -0
- package/dist/compat/address.js +69 -0
- package/dist/compat/bootstrap.d.ts +29 -0
- package/dist/compat/bootstrap.js +178 -0
- package/dist/compat/dht.d.ts +3 -0
- package/dist/compat/dht.js +9 -0
- package/dist/compat/express.d.ts +21 -0
- package/dist/compat/express.js +263 -0
- package/dist/compat/friend.d.ts +4 -0
- package/dist/compat/friend.js +12 -0
- package/dist/compat/net-crypto.d.ts +84 -0
- package/dist/compat/net-crypto.js +278 -0
- package/dist/compat/packet.d.ts +55 -0
- package/dist/compat/packet.js +154 -0
- package/dist/compat/session.d.ts +3 -0
- package/dist/compat/session.js +7 -0
- package/dist/compat/tcp-relay-pool.d.ts +85 -0
- package/dist/compat/tcp-relay-pool.js +342 -0
- package/dist/compat/tcp-relay.d.ts +96 -0
- package/dist/compat/tcp-relay.js +489 -0
- package/dist/compat/text.d.ts +3 -0
- package/dist/compat/text.js +8 -0
- package/dist/compat/tox-dht-crypto.d.ts +18 -0
- package/dist/compat/tox-dht-crypto.js +69 -0
- package/dist/compat/tox-onion.d.ts +66 -0
- package/dist/compat/tox-onion.js +172 -0
- package/dist/crypto/box.d.ts +1 -0
- package/dist/crypto/box.js +3 -0
- package/dist/crypto/keypair.d.ts +5 -0
- package/dist/crypto/keypair.js +37 -0
- package/dist/crypto/nonce.d.ts +1 -0
- package/dist/crypto/nonce.js +1 -0
- package/dist/crypto/sign.d.ts +1 -0
- package/dist/crypto/sign.js +3 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/peer.d.ts +45 -0
- package/dist/peer.js +3425 -0
- package/dist/runtime/errors.d.ts +3 -0
- package/dist/runtime/errors.js +6 -0
- package/dist/runtime/events.d.ts +4 -0
- package/dist/runtime/events.js +1 -0
- package/dist/runtime/lifecycle.d.ts +7 -0
- package/dist/runtime/lifecycle.js +12 -0
- package/dist/store/config.d.ts +2 -0
- package/dist/store/config.js +1 -0
- package/dist/store/friends.d.ts +13 -0
- package/dist/store/friends.js +1 -0
- package/dist/store/state.d.ts +3 -0
- package/dist/store/state.js +1 -0
- package/dist/transport/socket.d.ts +4 -0
- package/dist/transport/socket.js +1 -0
- package/dist/transport/tcp.d.ts +3 -0
- package/dist/transport/tcp.js +5 -0
- package/dist/transport/udp.d.ts +24 -0
- package/dist/transport/udp.js +90 -0
- package/dist/types/bootstrap.d.ts +2 -0
- package/dist/types/bootstrap.js +1 -0
- package/dist/types/dht.d.ts +3 -0
- package/dist/types/dht.js +1 -0
- package/dist/types/friend.d.ts +1 -0
- package/dist/types/friend.js +1 -0
- package/dist/types/message.d.ts +1 -0
- package/dist/types/message.js +1 -0
- package/dist/types/peer.d.ts +51 -0
- package/dist/types/peer.js +1 -0
- package/dist/types/session.d.ts +1 -0
- package/dist/types/session.js +1 -0
- package/dist/utils/base58.d.ts +2 -0
- package/dist/utils/base58.js +51 -0
- package/dist/utils/bytes.d.ts +4 -0
- package/dist/utils/bytes.js +31 -0
- package/docs/INSTALL.md +103 -0
- package/docs/USAGE_GUIDE.md +724 -0
- 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,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;
|