@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
package/dist/peer.js ADDED
@@ -0,0 +1,3425 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { networkInterfaces } from "node:os";
4
+ import { dirname } from "node:path";
5
+ import nacl from "tweetnacl";
6
+ import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
7
+ import { LegacyBootstrapClient } from "./compat/bootstrap.js";
8
+ import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket } from "./compat/packet.js";
9
+ import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
10
+ import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
11
+ import { NET_PACKET_CRYPTO_DATA, NET_PACKET_CRYPTO_HS, NET_PACKET_COOKIE_REQUEST, NET_PACKET_COOKIE_RESPONSE, createCookieRequest, createCookieResponse, createCryptoDataPacket, createCryptoHandshake, openCookieRequest, openCookieResponse, openCryptoDataPacket, openCryptoHandshake, incrementNonce } from "./compat/net-crypto.js";
12
+ import { LegacyExpressClient } from "./compat/express.js";
13
+ import { TcpRelayPool } from "./compat/tcp-relay-pool.js";
14
+ import { LegacyDhtClient } from "./compat/dht.js";
15
+ import { loadOrCreateKeyPair } from "./crypto/keypair.js";
16
+ import { base58ToBytes } from "./utils/base58.js";
17
+ import { UdpTransport } from "./transport/udp.js";
18
+ import { bytesToHex, concatBytes, randomBytes } from "./utils/bytes.js";
19
+ const ANNOUNCE_WAIT_TIMEOUT_MS = readEnvInt("DECENT_ANNOUNCE_WAIT_TIMEOUT_MS", 4000);
20
+ const MAX_FRIEND_ROUTE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ROUTE_MAX_ATTEMPTS", 12);
21
+ // How many in-flight announce step1 requests to fan out at once. Toxcore
22
+ // pipelines all 12 of its onion clients in parallel; we batch up to this
23
+ // many concurrent #sendAnnounceAndWait calls so the worst-case walk drops
24
+ // from (per-node-timeout × 12) ≈ 60s down to ~10s. Configurable via
25
+ // DECENT_FRIEND_ROUTE_BATCH_SIZE — set to 1 to revert to sequential.
26
+ const FRIEND_ROUTE_BATCH_SIZE = readEnvInt("DECENT_FRIEND_ROUTE_BATCH_SIZE", 8);
27
+ // Node soft-blacklist: after N consecutive failures (timeouts) a node
28
+ // is parked for a TTL window so we don't waste batch slots on it.
29
+ // Set NODE_BLACKLIST_THRESHOLD=0 to disable. Successful response from
30
+ // the same node clears it instantly.
31
+ const NODE_BLACKLIST_THRESHOLD = readEnvInt("DECENT_NODE_BLACKLIST_THRESHOLD", 5);
32
+ const NODE_BLACKLIST_BASE_TTL_MS = readEnvInt("DECENT_NODE_BLACKLIST_BASE_TTL_MS", 60_000);
33
+ const NODE_BLACKLIST_MAX_TTL_MS = readEnvInt("DECENT_NODE_BLACKLIST_MAX_TTL_MS", 600_000);
34
+ const FRIEND_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ANNOUNCE_ATTEMPTS", 1);
35
+ const JOIN_ANNOUNCE_TIMEOUT_MS = readEnvInt("DECENT_JOIN_ANNOUNCE_TIMEOUT_MS", 12000);
36
+ const SELF_ANNOUNCE_INTERVAL_MS = readEnvInt("DECENT_SELF_ANNOUNCE_INTERVAL_MS", 20000);
37
+ const MAX_SELF_ANNOUNCE_TARGETS = readEnvInt("DECENT_SELF_ANNOUNCE_TARGETS", 16);
38
+ const SELF_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_SELF_ANNOUNCE_ATTEMPTS", 3);
39
+ // Self-announce batch size — number of step1 / step2 requests to fan out
40
+ // at once. Toxcore's onion_client.c::do_announce keeps up to
41
+ // MAX_ONION_CLIENTS_ANNOUNCE (12) in flight. Configurable via env.
42
+ const SELF_ANNOUNCE_BATCH_SIZE = readEnvInt("DECENT_SELF_ANNOUNCE_BATCH_SIZE", 12);
43
+ const ONION_DATA_ATTEMPTS = readEnvInt("DECENT_ONION_DATA_ATTEMPTS", 5);
44
+ const EXPRESS_PULL_INTERVAL_MS = readEnvInt("DECENT_EXPRESS_PULL_INTERVAL_MS", 4000);
45
+ // Keepalive cadence. Toxcore's `friend_connection.h` uses
46
+ // FRIEND_PING_INTERVAL=8s and FRIEND_CONNECTION_TIMEOUT=32s, but our
47
+ // connection loop only ticks every 5s, so the actual ping cadence
48
+ // drifts to 8-13s. With a few drops in a row, iOS Beagle hits its 32s
49
+ // `ping_lastrecv` timeout, marks us offline in the UI, and re-issues
50
+ // a cookie request — observed as repeated `cookie request received`
51
+ // log lines while messages keep flowing on the underlying crypto
52
+ // session. Halve the threshold so the connection loop *always* sends
53
+ // a ping well within iOS's timeout window.
54
+ const FRIEND_PING_INTERVAL_MS = readEnvInt("DECENT_FRIEND_PING_INTERVAL_MS", 4000);
55
+ // Friend connection loop tick. Toxcore's main loop uses MIN_RUN_INTERVAL=50ms
56
+ // and dynamically goes up to 1s when idle (Messenger.c:2578). Our previous
57
+ // 2000ms fixed tick made every reactive event (DHT-PK send after a friend
58
+ // comes online, OOB cookie reply scheduling, ROUTING_REQUEST after we learn
59
+ // a relay) wait up to 2 seconds before firing. Default 250ms picks the
60
+ // middle of toxcore's reactive range — fast enough to keep responsiveness,
61
+ // slow enough not to burn CPU on idle peers. Tunable via env.
62
+ const FRIEND_CONNECTION_LOOP_MS = readEnvInt("DECENT_FRIEND_CONNECTION_LOOP_MS", 250);
63
+ const FRIEND_TIMEOUT_MS = readEnvInt("DECENT_FRIEND_TIMEOUT_MS", 32000);
64
+ const LAN_DISCOVERY_INTERVAL_MS = readEnvInt("DECENT_LAN_DISCOVERY_INTERVAL_MS", 10000);
65
+ // When a same-LAN friend's actual address is unknown (it's not in their
66
+ // DHT-PK extras and their NAT-mapped public IP doesn't accept our UDP),
67
+ // sweep every host in our local /24 subnet with a cookie request at the
68
+ // standard toxcore ports. Set to 0 to disable.
69
+ // LAN sweep: bursts ~250 cookie-request probes across the local /24 to
70
+ // rescue friends whose actual address isn't in their DHT-PK extras (e.g.
71
+ // iOS Simulator on the same Mac). This is *only* useful for same-LAN
72
+ // peer development. Against a real remote peer it produces 250+ wasted
73
+ // UDP packets per friend per cycle, which can saturate the local NAT
74
+ // table and cause every subsequent outbound onion announce to silently
75
+ // timeout (observed on Mac with two stale persisted friends — every
76
+ // announce step1 was dropped). Default OFF; opt in with
77
+ // DECENT_LAN_SWEEP_AFTER_MS=6000 if you're testing against an iOS
78
+ // Simulator on the same machine.
79
+ const LAN_SWEEP_AFTER_MS = readEnvInt("DECENT_LAN_SWEEP_AFTER_MS", 0);
80
+ // Default to a single port to keep the sweep burst small (~254 probes per
81
+ // /24 instead of ~1270). Override DECENT_LAN_SWEEP_PORTS=33445,33446,...
82
+ // only when probing for peers that bind to non-default ports.
83
+ const LAN_SWEEP_PORTS = (process.env.DECENT_LAN_SWEEP_PORTS ?? "33445")
84
+ .split(",")
85
+ .map((s) => Number.parseInt(s.trim(), 10))
86
+ .filter((n) => Number.isFinite(n) && n > 0 && n <= 0xffff);
87
+ // Optional comma-separated list of additional UDP hosts the LAN sweep
88
+ // should probe in addition to the /24 subnets we're attached to.
89
+ // Useful when testing against the iOS Simulator on the same host
90
+ // (DECENT_LAN_SWEEP_EXTRA_HOSTS=127.0.0.1) or a known fixed peer
91
+ // address. Default is empty so a real iOS device on the LAN behaves
92
+ // identically to before.
93
+ const LAN_SWEEP_EXTRA_HOSTS = (process.env.DECENT_LAN_SWEEP_EXTRA_HOSTS ?? "")
94
+ .split(",")
95
+ .map((s) => s.trim())
96
+ .filter((s) => s.length > 0);
97
+ const LAN_DISCOVERY_PORTS = (process.env.DECENT_LAN_DISCOVERY_PORTS ?? "33445,33446,33447,33448,33449")
98
+ .split(",")
99
+ .map((s) => Number.parseInt(s.trim(), 10))
100
+ .filter((n) => Number.isFinite(n) && n > 0 && n <= 0xffff);
101
+ const PEER_NICKNAME = process.env.DECENT_PEER_NAME ?? "@decentnetwork/peer";
102
+ const PEER_STATUS_MESSAGE = process.env.DECENT_PEER_STATUS_MESSAGE ?? "decent peer";
103
+ const GREETING_TEXT = process.env.DECENT_GREETING_TEXT ?? "";
104
+ // Toxcore Messenger.h packet IDs (live inside encrypted 0x1b crypto data plain payload)
105
+ const PACKET_ID_PADDING = 0;
106
+ const PACKET_ID_REQUEST = 1; // request retransmission of unreceived packets
107
+ const PACKET_ID_KILL = 2;
108
+ const PACKET_ID_ALIVE = 16; // keepalive
109
+ const PACKET_ID_SHARE_RELAYS = 17; // TCP relay sharing
110
+ const PACKET_ID_ONLINE = 24; // friend is online
111
+ const PACKET_ID_OFFLINE = 25; // friend going offline
112
+ const PACKET_ID_NICKNAME = 48; // friend's display name (UTF-8)
113
+ const PACKET_ID_STATUSMESSAGE = 49; // friend's status message
114
+ const PACKET_ID_USERSTATUS = 50; // friend's user status (1 byte enum)
115
+ const PACKET_ID_TYPING = 51; // typing indicator (1 byte bool)
116
+ const PACKET_ID_MESSAGE = 64; // text message (Carrier FlatBuffers payload)
117
+ const PACKET_ID_ACTION = 65; // /me action message
118
+ export class Peer {
119
+ #opts;
120
+ #events = new EventEmitter();
121
+ #keyPair;
122
+ #udp = new UdpTransport();
123
+ #bootstrap;
124
+ #dht = new LegacyDhtClient();
125
+ #knownNodes;
126
+ #announceDataKey;
127
+ #lastSelfAnnounceMs = 0;
128
+ #debug = process.env.DECENT_DEBUG === "1" || process.env.DECENT_DEBUG_VERBOSE === "1";
129
+ #debugVerbose = process.env.DECENT_DEBUG_VERBOSE === "1";
130
+ #packetTrace = process.env.DECENT_PACKET_TRACE === "1";
131
+ #lastFriendRequestDispatch;
132
+ #nodeHealth = new Map();
133
+ /**
134
+ * Soft blacklist: nodeId -> expiry timestamp (ms). Populated by
135
+ * #recordNodeFailure once a node has accumulated NODE_BLACKLIST_THRESHOLD
136
+ * consecutive failures without a success. Cleared by #recordNodeSuccess
137
+ * (any successful round-trip lifts the ban). Each candidate-selection
138
+ * site filters via #isNodeBlacklisted before adding a node to its queue.
139
+ */
140
+ #nodeBlacklist = new Map();
141
+ #pendingFriendRequests = new Map();
142
+ #friends = new Map();
143
+ #friendStoreFile;
144
+ #cookieSymmetricKey;
145
+ #friendSessions = new Map();
146
+ #express;
147
+ #expressPollTimer;
148
+ /**
149
+ * Pool of persistent TCP relay connections. Carrier bootstrap nodes
150
+ * also serve as TCP relay servers (toxcore TCP_server.c) on the same
151
+ * pubkey. iOS Beagle peers advertise *only* TCP relay endpoints in
152
+ * their DHT-PK extras (the 0x82 = TCP_FAMILY_IPV4 entries we saw),
153
+ * so without this pool we cannot interop with iOS at all when their
154
+ * UDP path is closed by NAT.
155
+ */
156
+ #tcpRelays;
157
+ #selfAnnounceTimer;
158
+ #friendConnectionTimer;
159
+ #lanDiscoveryTimer;
160
+ // Per-friend last DHT-PK send time, keyed by friendId, used even when no
161
+ // session entry exists yet so the connection loop does not flood DHT-PK
162
+ // requests when route discovery keeps failing.
163
+ #dhtPkSendCooldown = new Map();
164
+ // Per-friend consecutive "no routes available" count for DHT-PK so the
165
+ // backoff grows if a friend stays unreachable, instead of retrying every
166
+ // 25s forever for stale persisted entries.
167
+ #dhtPkConsecutiveFailures = new Map();
168
+ #lastSelfAnnounceStoredCount = -1;
169
+ // Per-friend last-logged route count from #discoverFriendRoutes so we
170
+ // only emit a "routes=N for friend=…" debug line when it changes.
171
+ #lastLoggedRoutesForFriend = new Map();
172
+ // Per-friend last "cookie_sent" key (friend+host+port) so retries to the
173
+ // same endpoint don't spam debug; the first attempt and any change still log.
174
+ #lastCookieSentKey = new Map();
175
+ // Per-friend last endpoint we logged via "endpoint_selected" — same purpose
176
+ // as #lastCookieSentKey but for the selection step. Without dedupe the log
177
+ // line fired every loop tick because `session.remote` oscillates between
178
+ // candidates as inbound DHT-PK extras arrive.
179
+ #lastEndpointSelectedKey = new Map();
180
+ // Per-friend consecutive cookie-request failures (no matching cookie
181
+ // response received before the next attempt). Used to compute exponential
182
+ // backoff so an unreachable persisted friend stops dominating the friend
183
+ // connection loop with cookie-request bursts every 8 seconds.
184
+ #cookieRetryCount = new Map();
185
+ // Per-friend "we already warned this peer is TCP-only" set.
186
+ #tcpOnlyWarningShown = new Set();
187
+ // Per-friend "we already noted there's no known endpoint" set so the
188
+ // #initiateSession path doesn't spam every connection-loop tick.
189
+ #noEndpointWarned = new Set();
190
+ // Per-friend "we already logged that we're deferring initiation to the
191
+ // higher-pubkey peer" set. The connection loop re-enters
192
+ // #initiateSession every few seconds for every friend; without this
193
+ // dedupe, lower-pubkey side floods the log with "deferring to peer".
194
+ #initiateSkipLogged = new Set();
195
+ // Per-friend tracking sets so we send our nickname/status/greeting once
196
+ // per session rather than on every PACKET_ID_ONLINE arrival.
197
+ #profileSentTo = new Set();
198
+ #greetingSentTo = new Set();
199
+ #selfAnnouncePromise;
200
+ #selfAnnouncePauseDepth = 0;
201
+ #started = false;
202
+ constructor(opts) {
203
+ this.#opts = {
204
+ ...opts,
205
+ compatibilityMode: opts.compatibilityMode ?? "legacy"
206
+ };
207
+ this.#knownNodes = [...opts.bootstrapNodes];
208
+ this.#friendStoreFile = opts.friendStoreFile ?? `${opts.keyFile}.friends.json`;
209
+ }
210
+ static async create(opts) {
211
+ if (opts.compatibilityMode && opts.compatibilityMode !== "legacy") {
212
+ throw new Error("Only legacy compatibility mode is supported in this skeleton");
213
+ }
214
+ if (!opts.bootstrapNodes.length) {
215
+ throw new Error("At least one bootstrap node is required");
216
+ }
217
+ return new Peer(opts);
218
+ }
219
+ async start() {
220
+ if (this.#started) {
221
+ return;
222
+ }
223
+ this.#keyPair = await loadOrCreateKeyPair(this.#opts.keyFile);
224
+ this.#bootstrap = new LegacyBootstrapClient({
225
+ nodes: this.#opts.bootstrapNodes,
226
+ keyPair: this.#keyPair,
227
+ transport: this.#udp
228
+ });
229
+ const announceGenerated = createEphemeralKeyPair();
230
+ this.#announceDataKey = {
231
+ publicKey: announceGenerated.publicKey,
232
+ secretKey: announceGenerated.secretKey
233
+ };
234
+ this.#cookieSymmetricKey = randomBytes(32);
235
+ await this.#loadPersistedFriends();
236
+ if (this.#opts.expressNodes && this.#opts.expressNodes.length > 0) {
237
+ this.#express = new LegacyExpressClient({
238
+ nodes: this.#opts.expressNodes,
239
+ selfKeyPair: this.#keyPair,
240
+ selfUserId: this.userid(),
241
+ selfAddress: this.address(),
242
+ callbacks: {
243
+ onOfflineFriendRequest: (fromUserId, packet) => {
244
+ this.#emitOfflineFriendRequest(fromUserId, packet);
245
+ },
246
+ onOfflineFriendMessage: (fromUserId, packet) => {
247
+ this.#emitOfflineFriendMessage(fromUserId, packet);
248
+ }
249
+ }
250
+ });
251
+ }
252
+ this.#udp.on("datagram", this.#onDatagram);
253
+ await this.#udp.start();
254
+ // Spin up the TCP relay pool. We pass the same bootstrap node list
255
+ // we already have — every Carrier bootstrap is also a TCP relay
256
+ // server on the same pubkey. The pool opens up to 3 connections in
257
+ // parallel; failures don't abort start because pool internally
258
+ // schedules reconnects.
259
+ if (this.#opts.bootstrapNodes.length > 0) {
260
+ // maxConnections matches toxcore's default (3). Initial overlap
261
+ // with another peer drawing 3 from the same 14-node bootstrap
262
+ // list is ~21% — looks low, but the gap is closed by the
263
+ // dynamic-relay-add path: when a friend's DHT-PK announce
264
+ // arrives carrying TCP_FAMILY_IPV4 entries in extras, we open
265
+ // those specific relays (see #handleOnionDhtPk). After one
266
+ // round-trip of DHT-PK announces (which we send every ~25s for
267
+ // every non-established friend), the pool naturally grows to
268
+ // include every relay any of our friends actually use.
269
+ //
270
+ // Override with DECENT_TCP_MAX_RELAYS=N to test brute-force
271
+ // overlap (set N up to bootstrapNodes.length).
272
+ const maxRelays = Number.parseInt(process.env.DECENT_TCP_MAX_RELAYS ?? "3", 10);
273
+ this.#tcpRelays = new TcpRelayPool({
274
+ relays: this.#opts.bootstrapNodes,
275
+ selfKeyPair: this.#keyPair,
276
+ maxConnections: maxRelays,
277
+ label: this.#opts.debugLabel
278
+ });
279
+ this.#tcpRelays.on("peerData", (friendKey, payload) => {
280
+ this.#handleTcpDatagram(friendKey, payload);
281
+ });
282
+ this.#tcpRelays.on("friendOnline", (friendKey) => {
283
+ const friendId = carrierIdFromPublicKey(friendKey);
284
+ this.#debugLog(`tcp_relay friend_online ${friendId}`);
285
+ // Mark the (existing or new) session as having a TCP route so
286
+ // outbound goes through the relay even if no UDP endpoint is
287
+ // known. Then kick #initiateSession — this issues a cookie
288
+ // request that will travel via TCP if no UDP path exists.
289
+ const session = this.#friendSessions.get(friendId) ?? this.#newSessionShell();
290
+ session.hasTcpRoute = true;
291
+ session.friendRealPublicKey ??= new Uint8Array(friendKey);
292
+ this.#friendSessions.set(friendId, session);
293
+ if (!session.established) {
294
+ void this.#initiateSession(friendId).catch(() => { });
295
+ }
296
+ });
297
+ this.#tcpRelays.on("friendOffline", (friendKey) => {
298
+ const friendId = carrierIdFromPublicKey(friendKey);
299
+ const session = this.#friendSessions.get(friendId);
300
+ if (session) {
301
+ session.hasTcpRoute = false;
302
+ }
303
+ this.#debugLog(`tcp_relay friend_offline ${friendId}`);
304
+ });
305
+ this.#tcpRelays.on("status", (connected, total) => {
306
+ this.#debugLog(`tcp_pool ${connected}/${total} relays connected`);
307
+ });
308
+ // OOB_RECV bridge. iPad's friend_connection issues OOB_SEND with a
309
+ // cookie request payload (NET_PACKET_COOKIE_REQUEST = 0x18) when
310
+ // it can't find our DHT-PK announce on the DHT — exactly the
311
+ // situation we hit on residential ISPs. Toxcore handles the
312
+ // symmetric case in net_crypto.c::tcp_oob_callback. Feed any
313
+ // such inbound through the same #onDatagram dispatcher we use
314
+ // for UDP, with a synthetic tcp: remote so session.remote isn't
315
+ // poisoned with a fake UDP host.
316
+ this.#tcpRelays.on("oob", (senderKey, payload) => {
317
+ if (payload.length === 0)
318
+ return;
319
+ const kind = payload[0];
320
+ // We currently only act on cookie request / response / handshake.
321
+ // Friend-request OOB delivery uses a different path
322
+ // (CRYPTO_PACKET_FRIEND_REQ inside an onion data packet).
323
+ // NOTE: cookie *response* (0x19) was previously dropped here,
324
+ // which broke TCP-relay-only friend pairing — added 0x19 so the
325
+ // initiator side can receive its response.
326
+ if (kind !== NET_PACKET_COOKIE_REQUEST &&
327
+ kind !== NET_PACKET_COOKIE_RESPONSE &&
328
+ kind !== NET_PACKET_CRYPTO_HS) {
329
+ return;
330
+ }
331
+ const senderId = carrierIdFromPublicKey(senderKey);
332
+ this.#debugLog(`tcp_oob_recv from ${senderId} kind=0x${kind.toString(16)} len=${payload.length}`);
333
+ // Issue ROUTING_REQUEST for the sender's DHT pubkey on every
334
+ // connected relay so the relay links us with cid. Without
335
+ // this, post-handshake messenger traffic (PACKET_ID_ONLINE,
336
+ // ALIVE, etc.) has no way out via TCP — sendToFriend returns
337
+ // 0 because no cid maps to the sender's pubkey, and iPad
338
+ // never sees us as online even though the crypto session is
339
+ // up. Idempotent: already-requested routes are a no-op on
340
+ // each underlying client.
341
+ this.#tcpRelays?.requestRoute(senderKey);
342
+ this.#handleTcpDatagram(senderKey, payload);
343
+ });
344
+ // Don't await — pool reconnects on its own and we want start() to
345
+ // return promptly so the demo can call joinNetwork() in parallel.
346
+ void this.#tcpRelays.start().catch((error) => {
347
+ this.#debugLog(`tcp pool initial start failed: ${error.message}`);
348
+ });
349
+ // Pre-route every persisted friend so the pool watches for them
350
+ // the moment a relay is up.
351
+ for (const friend of this.#friends.values()) {
352
+ try {
353
+ const pk = base58ToBytes(friend.pubkey);
354
+ if (pk.length === 32)
355
+ this.#tcpRelays.requestRoute(pk);
356
+ }
357
+ catch { /* skip malformed */ }
358
+ }
359
+ }
360
+ this.#started = true;
361
+ }
362
+ /** Lazy session shell used when we want to attach state before any handshake. */
363
+ #newSessionShell() {
364
+ return {
365
+ ourSessionKeyPair: createEphemeralKeyPair(),
366
+ ourBaseNonce: randomBytes(24)
367
+ };
368
+ }
369
+ async stop() {
370
+ if (this.#tcpRelays) {
371
+ await this.#tcpRelays.stop().catch(() => { });
372
+ this.#tcpRelays = undefined;
373
+ }
374
+ if (this.#expressPollTimer) {
375
+ clearInterval(this.#expressPollTimer);
376
+ this.#expressPollTimer = undefined;
377
+ }
378
+ if (this.#selfAnnounceTimer) {
379
+ clearInterval(this.#selfAnnounceTimer);
380
+ this.#selfAnnounceTimer = undefined;
381
+ }
382
+ if (this.#friendConnectionTimer) {
383
+ clearInterval(this.#friendConnectionTimer);
384
+ this.#friendConnectionTimer = undefined;
385
+ }
386
+ this.#udp.off("datagram", this.#onDatagram);
387
+ await this.#udp.stop();
388
+ this.#started = false;
389
+ }
390
+ pubkey() {
391
+ if (!this.#keyPair) {
392
+ throw new Error("Peer is not started");
393
+ }
394
+ return bytesToHex(this.#keyPair.publicKey);
395
+ }
396
+ userid() {
397
+ if (!this.#keyPair) {
398
+ throw new Error("Peer is not started");
399
+ }
400
+ return carrierIdFromPublicKey(this.#keyPair.publicKey);
401
+ }
402
+ address() {
403
+ if (!this.#keyPair) {
404
+ throw new Error("Peer is not started");
405
+ }
406
+ return carrierAddressFromPublicKey(this.#keyPair.publicKey);
407
+ }
408
+ async joinNetwork() {
409
+ if (!this.#bootstrap) {
410
+ throw new Error("Peer is not started");
411
+ }
412
+ const result = await this.#bootstrap.join();
413
+ this.#recordNodeSuccess(`${result.respondingNode.host}:${result.respondingNode.port}`);
414
+ const discovered = result.discoveredNodes.map((node) => ({
415
+ host: node.host,
416
+ port: node.port,
417
+ pk: carrierIdFromPublicKey(node.publicKey)
418
+ }));
419
+ this.#knownNodes = dedupeNodes([result.respondingNode, ...discovered, ...this.#opts.bootstrapNodes]);
420
+ await this.#runSelfAnnounce(false, Date.now() + JOIN_ANNOUNCE_TIMEOUT_MS);
421
+ this.#ensureSelfAnnounceLoop();
422
+ this.#ensureFriendConnectionLoop();
423
+ this.#ensureExpressPullLoop();
424
+ // Kick off an immediate friend connection cycle so persisted friends with
425
+ // a cached endpoint try to reconnect right away instead of waiting 5s.
426
+ void this.#doFriendConnections().catch((error) => {
427
+ this.#debugLog(`initial friend connection cycle failed: ${error.message}`);
428
+ });
429
+ return result;
430
+ }
431
+ async lookup(pubkey) {
432
+ return this.#dht.lookup(pubkey);
433
+ }
434
+ async announceSelf(timeoutMs = 15000) {
435
+ return this.#runSelfAnnounce(true, Date.now() + timeoutMs);
436
+ }
437
+ addKnownNodes(nodes) {
438
+ this.#knownNodes = dedupeNodes([...nodes, ...this.#knownNodes]);
439
+ }
440
+ knownNodes() {
441
+ return [...this.#knownNodes];
442
+ }
443
+ async sendFriendRequest(pubkey, hello) {
444
+ if (!this.#keyPair || !this.#announceDataKey) {
445
+ throw new Error("Peer is not started");
446
+ }
447
+ // Idempotency guard: if this friend was previously accepted (we have
448
+ // ever had a real session with them), skip the friend-request round
449
+ // trip entirely. Sending it again would be visible to iOS as a fresh
450
+ // friend request prompt — worst case the user dismisses it, best case
451
+ // it's silently ignored since iOS already has us. We still emit the
452
+ // last-dispatch summary (transport=cached) so the demo's retry loop
453
+ // recognizes "this counted as a successful dispatch" and proceeds to
454
+ // wait for connection events. The friend connection loop will pick up
455
+ // session establishment via DHT-PK on its own cadence.
456
+ const friendAddress = parseCarrierAddress(pubkey);
457
+ const friendId = carrierIdFromPublicKey(friendAddress.publicKey);
458
+ const existing = this.#friends.get(friendId);
459
+ if (existing?.acceptedAt) {
460
+ this.#debugLog(`friend request skipped — ${friendId} already accepted at ${new Date(existing.acceptedAt).toISOString()}`);
461
+ this.#lastFriendRequestDispatch = {
462
+ transport: "onion",
463
+ routes: 0,
464
+ targets: 0
465
+ };
466
+ // Kick the connection loop so we don't have to wait for the next
467
+ // 2s tick. Best-effort — failures here are non-fatal because the
468
+ // periodic loop will keep retrying.
469
+ void this.#initiateSession(friendId).catch(() => { });
470
+ return;
471
+ }
472
+ const resumeSelfAnnounce = this.#pauseSelfAnnounce();
473
+ try {
474
+ if (this.#selfAnnouncePromise) {
475
+ await this.#selfAnnouncePromise.catch(() => { });
476
+ }
477
+ await this.#announceSelfBestEffort(false, Date.now() + 4000);
478
+ const appPayload = encodeFriendRequestPacket({
479
+ name: PEER_NICKNAME,
480
+ descr: PEER_STATUS_MESSAGE || "decent peer",
481
+ hello: hello ?? ""
482
+ });
483
+ const friendReqPayload = concatBytes([
484
+ uint32ToLe(friendAddress.nospam),
485
+ appPayload
486
+ ]);
487
+ const routes = await this.#discoverFriendRoutes(friendAddress.publicKey);
488
+ this.#debugLog(`friend route discovery done routes=${routes.length}`);
489
+ if (routes.length === 0) {
490
+ await this.#sendDirectCryptoFriendRequest(friendAddress.publicKey, friendReqPayload);
491
+ this.#lastFriendRequestDispatch = {
492
+ transport: "direct",
493
+ routes: 0,
494
+ targets: this.#knownNodes.length > 0 ? this.#knownNodes.length : this.#opts.bootstrapNodes.length
495
+ };
496
+ this.#recordOutgoingFriendRequest(friendId, pubkey, friendAddress.nospam, hello);
497
+ if (this.#express?.hasNodes()) {
498
+ void this.#express.sendOfflineFriendRequest(pubkey, appPayload)
499
+ .then(() => {
500
+ this.#debugLog(`offline_fr_post ok to ${pubkey}`);
501
+ })
502
+ .catch((error) => {
503
+ this.#debugLog(`offline friend request dispatch failed: ${error.message}`);
504
+ });
505
+ }
506
+ return;
507
+ }
508
+ let sent = 0;
509
+ const errors = [];
510
+ for (const route of routes) {
511
+ try {
512
+ const nonce = randomBytes(24);
513
+ const onionData = createOnionDataPacket({
514
+ senderPublicKey: this.#keyPair.publicKey,
515
+ senderSecretKey: this.#keyPair.secretKey,
516
+ receiverPublicKey: friendAddress.publicKey,
517
+ nonce,
518
+ innerPacketId: ONION_FRIEND_REQUEST_ID,
519
+ innerPayload: friendReqPayload
520
+ });
521
+ const dataRequest = createOnionDataRequest({
522
+ destinationPublicKey: friendAddress.publicKey,
523
+ routePublicKey: route.routePublicKey,
524
+ nonce,
525
+ onionDataPacket: onionData
526
+ });
527
+ for (let attempt = 0; attempt < ONION_DATA_ATTEMPTS; attempt++) {
528
+ await this.#sendThroughOnionPath(dataRequest, route.node, attempt);
529
+ sent += 1;
530
+ await sleep(150);
531
+ }
532
+ }
533
+ catch (error) {
534
+ errors.push(`${route.node.host}:${route.node.port} ${error.message}`);
535
+ }
536
+ }
537
+ if (sent === 0) {
538
+ throw new Error(`friend request dispatch failed: ${errors.join("; ")}`);
539
+ }
540
+ this.#lastFriendRequestDispatch = {
541
+ transport: "onion",
542
+ routes: routes.length,
543
+ targets: sent
544
+ };
545
+ this.#recordOutgoingFriendRequest(friendId, pubkey, friendAddress.nospam, hello);
546
+ if (this.#express?.hasNodes()) {
547
+ void this.#express.sendOfflineFriendRequest(pubkey, appPayload).catch((error) => {
548
+ this.#debugLog(`offline friend request dispatch failed: ${error.message}`);
549
+ });
550
+ }
551
+ }
552
+ finally {
553
+ resumeSelfAnnounce();
554
+ }
555
+ }
556
+ lastFriendRequestDispatch() {
557
+ return this.#lastFriendRequestDispatch;
558
+ }
559
+ async acceptFriendRequest(pubkey) {
560
+ const request = this.#pendingFriendRequests.get(pubkey);
561
+ if (!request) {
562
+ throw new Error("No pending friend request from this peer");
563
+ }
564
+ this.#pendingFriendRequests.delete(pubkey);
565
+ this.#friends.set(pubkey, {
566
+ pubkey,
567
+ userid: request.userid,
568
+ address: request.address,
569
+ nospam: request.nospam,
570
+ name: request.name,
571
+ description: request.description,
572
+ hello: request.hello,
573
+ status: "offline",
574
+ acceptedAt: Date.now()
575
+ });
576
+ this.#persistFriends();
577
+ this.#events.emit("friendConnection", {
578
+ pubkey,
579
+ status: "disconnected"
580
+ });
581
+ // Pre-route on TCP relays so the relay starts watching for them.
582
+ if (this.#tcpRelays) {
583
+ try {
584
+ const pk = base58ToBytes(pubkey);
585
+ if (pk.length === 32)
586
+ this.#tcpRelays.requestRoute(pk);
587
+ }
588
+ catch { /* skip malformed */ }
589
+ }
590
+ // Attempt to start net_crypto session immediately. If we don't have a
591
+ // remote endpoint cached yet, the friend connection loop will retry
592
+ // periodically. The peer side will also attempt to initiate, so even if
593
+ // our initiation fails, the responder path can still complete the session.
594
+ void this.#initiateSession(pubkey).catch((error) => {
595
+ this.#debugLog(`accept friend: initiate session failed for ${pubkey}: ${error.message}`);
596
+ });
597
+ }
598
+ rejectFriendRequest(pubkey) {
599
+ this.#pendingFriendRequests.delete(pubkey);
600
+ }
601
+ /**
602
+ * Drop a friend entirely: tear down any active net_crypto session, forget
603
+ * their cached endpoint, clear retry/backoff bookkeeping, and persist the
604
+ * shrunk friend list to disk. Use this to remove stale persisted entries
605
+ * (e.g. an old simulator pubkey) that are still hogging the friend
606
+ * connection loop with cookie requests no peer is going to answer.
607
+ *
608
+ * `pubkey` accepts either the friend's userid (base58 of the real public
609
+ * key) or full Carrier address — both resolve to the same friendId.
610
+ */
611
+ removeFriend(pubkey) {
612
+ let friendId = pubkey;
613
+ try {
614
+ friendId = carrierIdFromAddress(pubkey);
615
+ }
616
+ catch {
617
+ // Not an address; assume already a userid / pubkey form.
618
+ }
619
+ const existed = this.#friends.delete(friendId);
620
+ this.#friendSessions.delete(friendId);
621
+ this.#pendingFriendRequests.delete(friendId);
622
+ this.#cookieRetryCount.delete(friendId);
623
+ this.#lastCookieSentKey.delete(friendId);
624
+ this.#lastEndpointSelectedKey.delete(friendId);
625
+ this.#lastLoggedRoutesForFriend.delete(friendId);
626
+ this.#dhtPkSendCooldown.delete(friendId);
627
+ this.#dhtPkConsecutiveFailures.delete(friendId);
628
+ this.#tcpOnlyWarningShown.delete(friendId);
629
+ this.#noEndpointWarned.delete(friendId);
630
+ this.#profileSentTo.delete(friendId);
631
+ this.#greetingSentTo.delete(friendId);
632
+ if (existed) {
633
+ this.#persistFriends();
634
+ this.#debugLog(`removed friend ${friendId}`);
635
+ }
636
+ return existed;
637
+ }
638
+ #recordOutgoingFriendRequest(friendId, address, nospam, hello) {
639
+ const existing = this.#friends.get(friendId);
640
+ if (existing?.status === "online" || existing?.acceptedAt) {
641
+ return;
642
+ }
643
+ this.#friends.set(friendId, {
644
+ ...existing,
645
+ pubkey: friendId,
646
+ userid: friendId,
647
+ address,
648
+ nospam,
649
+ hello: hello ?? existing?.hello,
650
+ status: existing?.status ?? "requested"
651
+ });
652
+ this.#persistFriends();
653
+ // Tell every connected TCP relay we want this friend routed; the
654
+ // moment they're also connected, the relay sends us a CONNECTION_
655
+ // NOTIFICATION and we can establish a session over TCP even if UDP
656
+ // doesn't work (which is iOS's normal case).
657
+ if (this.#tcpRelays) {
658
+ try {
659
+ const pk = parseCarrierAddress(address).publicKey;
660
+ this.#tcpRelays.requestRoute(pk);
661
+ }
662
+ catch { /* address parse failure already logged elsewhere */ }
663
+ }
664
+ }
665
+ async sendText(pubkey, text) {
666
+ const friend = this.#friends.get(pubkey);
667
+ if (!friend) {
668
+ throw new Error(`Not a friend: ${pubkey}`);
669
+ }
670
+ let session = this.#friendSessions.get(pubkey);
671
+ if (!session?.established) {
672
+ // Try to bring up the session if we know the friend's endpoint
673
+ await this.#initiateSession(pubkey).catch(() => { });
674
+ const established = await this.#waitForFriendConnected(pubkey, 5000).catch(() => false);
675
+ if (established) {
676
+ session = this.#friendSessions.get(pubkey);
677
+ }
678
+ }
679
+ const packet = encodeFriendMessagePacket(text);
680
+ // Try the live in-session path first when any transport is up
681
+ // (UDP via session.remote, or TCP relay via session.hasTcpRoute).
682
+ // #sendMessengerPacket internally fans out to both transports and
683
+ // returns true if at least one accepted; throws only if zero did.
684
+ const liveSession = session?.established
685
+ && session.sessionSharedKey
686
+ && session.ourBaseNonce
687
+ && (session.remote || session.hasTcpRoute);
688
+ if (liveSession) {
689
+ try {
690
+ await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, packet);
691
+ return;
692
+ }
693
+ catch (error) {
694
+ // Mirrors Carrier C SDK behavior: when an in-session send fails
695
+ // (no transport accepted the encrypted packet), fall through to
696
+ // the express offline relay rather than dropping the message
697
+ // on the floor. The receiver will pick it up on their next
698
+ // express pull. Without this the message is just lost when the
699
+ // friend's NAT briefly forgets us — observed when iPad rebinds
700
+ // its TCP relay slot, or when UDP and TCP relay both blip.
701
+ this.#debugLog(`sendText: in-session send failed for ${pubkey}, falling back to express: ${error.message}`);
702
+ }
703
+ }
704
+ // Offline / fallback path via Carrier express HTTP store-and-forward.
705
+ if (this.#express?.hasNodes()) {
706
+ await this.#express.sendOfflineText(pubkey, packet);
707
+ this.#debugLog(`sendText: queued via express for ${pubkey}`);
708
+ return;
709
+ }
710
+ throw new Error("friend is offline and no express node is configured");
711
+ }
712
+ waitForFriendConnected(pubkey, timeoutMs = 30000) {
713
+ return this.#waitForFriendConnected(pubkey, timeoutMs);
714
+ }
715
+ #waitForFriendConnected(pubkey, timeoutMs) {
716
+ const session = this.#friendSessions.get(pubkey);
717
+ if (session?.established) {
718
+ return Promise.resolve(true);
719
+ }
720
+ return new Promise((resolve) => {
721
+ const timer = setTimeout(() => {
722
+ this.#events.off("friendConnection", onConnection);
723
+ resolve(false);
724
+ }, timeoutMs);
725
+ const onConnection = (ev) => {
726
+ if (ev.pubkey === pubkey && ev.status === "connected") {
727
+ clearTimeout(timer);
728
+ this.#events.off("friendConnection", onConnection);
729
+ resolve(true);
730
+ }
731
+ };
732
+ this.#events.on("friendConnection", onConnection);
733
+ });
734
+ }
735
+ onFriendRequest(cb) {
736
+ this.#events.on("friendRequest", cb);
737
+ }
738
+ onText(cb) {
739
+ this.#events.on("text", cb);
740
+ }
741
+ onFriendConnection(cb) {
742
+ this.#events.on("friendConnection", cb);
743
+ }
744
+ onFriendInfo(cb) {
745
+ this.#events.on("friendInfo", cb);
746
+ }
747
+ friends() {
748
+ return [...this.#friends.values()];
749
+ }
750
+ waitForFriendRequest(timeoutMs = 30000) {
751
+ return new Promise((resolve, reject) => {
752
+ const timer = setTimeout(() => {
753
+ cleanup();
754
+ reject(new Error(`timed out waiting for friend request after ${timeoutMs}ms`));
755
+ }, timeoutMs);
756
+ const onRequest = (request) => {
757
+ cleanup();
758
+ resolve(request);
759
+ };
760
+ const cleanup = () => {
761
+ clearTimeout(timer);
762
+ this.#events.off("friendRequest", onRequest);
763
+ };
764
+ this.#events.on("friendRequest", onRequest);
765
+ });
766
+ }
767
+ /**
768
+ * Bridge for inbound TCP-relayed packets. The relay forwards the
769
+ * exact bytes the friend sent (cookie request/response, handshake,
770
+ * crypto data) — same wire format as UDP minus the iveg magic
771
+ * prefix (the relay strips its own framing). We re-use the existing
772
+ * UDP datagram dispatch by synthesizing a remote whose `address`
773
+ * starts with `tcp:` so downstream code can recognize the origin
774
+ * and avoid setting `session.remote` to a UDP-shaped value.
775
+ */
776
+ #handleTcpDatagram(friendKey, payload) {
777
+ const friendId = carrierIdFromPublicKey(friendKey);
778
+ // Mark the session as TCP-routed if we don't have it yet.
779
+ let session = this.#friendSessions.get(friendId);
780
+ if (!session) {
781
+ session = this.#newSessionShell();
782
+ session.friendRealPublicKey = new Uint8Array(friendKey);
783
+ this.#friendSessions.set(friendId, session);
784
+ }
785
+ session.hasTcpRoute = true;
786
+ session.friendRealPublicKey ??= new Uint8Array(friendKey);
787
+ // Feed through the regular dispatcher with synthetic remote.
788
+ this.#onDatagram({
789
+ data: Buffer.from(payload),
790
+ remote: { address: `tcp:${friendId}`, port: 0 }
791
+ });
792
+ }
793
+ /** True when the synthetic remote was constructed by #handleTcpDatagram. */
794
+ #remoteIsTcp(remote) {
795
+ return remote.address.startsWith("tcp:");
796
+ }
797
+ #onDatagram = ({ data, remote }) => {
798
+ if (!this.#keyPair) {
799
+ return;
800
+ }
801
+ const packet = stripCarrierMagic(data);
802
+ if (packet.length === 0) {
803
+ return;
804
+ }
805
+ this.#tracePacket("rx", packet, { host: remote.address, port: remote.port });
806
+ if (packet[0] === NET_PACKET_CRYPTO) {
807
+ const opened = openToxDhtCryptoRequest(packet, this.#keyPair);
808
+ if (!opened) {
809
+ return;
810
+ }
811
+ if (opened.requestId === CRYPTO_PACKET_DHTPK) {
812
+ this.#handleOnionDhtPk(opened.senderPublicKey, opened.data);
813
+ return;
814
+ }
815
+ if (opened.requestId !== CRYPTO_PACKET_FRIEND_REQ) {
816
+ return;
817
+ }
818
+ const friendRequest = splitFriendRequestPayload(opened.data);
819
+ if (!friendRequest) {
820
+ this.#debugLog(`direct friend request payload decode failed from ${remote.address}:${remote.port}`);
821
+ return;
822
+ }
823
+ this.#emitFriendRequest(opened.senderPublicKey, friendRequest.carrierPacket, friendRequest.nospam);
824
+ return;
825
+ }
826
+ if (packet[0] === NET_PACKET_COOKIE_REQUEST && this.#cookieSymmetricKey) {
827
+ const request = openCookieRequest(packet, {
828
+ receiverDhtSecretKey: this.#keyPair.secretKey
829
+ });
830
+ if (!request) {
831
+ this.#debugLog(`cookie request parse failed from ${remote.address}:${remote.port}`);
832
+ return;
833
+ }
834
+ const senderId = carrierIdFromPublicKey(request.senderRealPublicKey);
835
+ const fromTcp = this.#remoteIsTcp(remote);
836
+ this.#debugLog(`cookie request received from ${senderId} via ${remote.address}:${remote.port}${fromTcp ? " (tcp)" : ""}`);
837
+ // Cache friend's known UDP endpoint only when this came over UDP.
838
+ // For TCP-routed cookie requests the synthetic "tcp:<id>:0" remote
839
+ // would otherwise poison the UDP send-back path.
840
+ if (!fromTcp) {
841
+ this.#cacheFriendRemote(senderId, remote.address, remote.port, request.senderRealPublicKey, request.senderDhtPublicKey);
842
+ }
843
+ const response = createCookieResponse({
844
+ request,
845
+ receiverDhtSecretKey: this.#keyPair.secretKey,
846
+ receiverCookieSymmetricKey: this.#cookieSymmetricKey
847
+ });
848
+ if (fromTcp) {
849
+ // Reply via TCP relay OOB_SEND. The relay forwards as OOB_RECV
850
+ // to the destination. **Critical**: the relay routes OOB by
851
+ // the destination's DHT pubkey (= the pubkey they handshook
852
+ // the relay with), NOT by their real pubkey. iOS Beagle peers
853
+ // use a different DHT pk than real pk (real "7se1FY..." vs
854
+ // DHT "GgAxwFFP..."). If we send OOB to the real pk the
855
+ // relay's lookup table misses and the response is silently
856
+ // dropped — iOS then keeps re-sending the cookie request
857
+ // forever (observed in real testing as 6+ duplicate
858
+ // tcp_oob_recv lines with no hs_recv follow-up).
859
+ const sent = this.#tcpRelays?.sendOobToFriend(request.senderDhtPublicKey, response) ?? 0;
860
+ if (sent > 0) {
861
+ this.#debugLog(`cookie response sent via tcp_oob to ${senderId} dhtpk=${carrierIdFromPublicKey(request.senderDhtPublicKey)} (relays=${sent})`);
862
+ }
863
+ else {
864
+ this.#debugLog(`cookie response: no tcp relay accepted oob send for ${senderId}`);
865
+ }
866
+ }
867
+ else {
868
+ void this.#sendPacket(response, { host: remote.address, port: remote.port })
869
+ .then(() => {
870
+ this.#debugLog(`cookie response sent to ${remote.address}:${remote.port}`);
871
+ })
872
+ .catch((error) => {
873
+ this.#debugLog(`cookie response send failed: ${error.message}`);
874
+ });
875
+ }
876
+ return;
877
+ }
878
+ if (packet[0] === NET_PACKET_COOKIE_RESPONSE) {
879
+ // Match cookie response by successful decrypt + echo, not by guessed
880
+ // endpoint. Friends may reply from a different port than we targeted.
881
+ let matchedFriendId;
882
+ let matchedSession;
883
+ let opened;
884
+ for (const [friendId, session] of this.#friendSessions) {
885
+ if (!session.friendRealPublicKey) {
886
+ continue;
887
+ }
888
+ // Allow currently-pending echo OR any echo from the recent history
889
+ // (last 30s). This prevents losing legitimate cookie responses when
890
+ // the friend connection loop rotated to a new echo while a LAN
891
+ // sweep response was in flight from the old echo.
892
+ const hasPending = session.pendingEcho !== undefined;
893
+ const recent = session.recentEchoes;
894
+ if (!hasPending && (!recent || recent.size === 0)) {
895
+ continue;
896
+ }
897
+ const responsePeerDhtKey = session.pendingCookiePeerDhtPublicKey ?? session.friendDhtPublicKey;
898
+ if (!responsePeerDhtKey) {
899
+ continue;
900
+ }
901
+ const candidate = openCookieResponse(packet, {
902
+ receiverDhtPublicKey: responsePeerDhtKey,
903
+ senderDhtSecretKey: this.#keyPair.secretKey
904
+ });
905
+ if (!candidate) {
906
+ continue;
907
+ }
908
+ const echoMatches = (hasPending && candidate.echo === session.pendingEcho) ||
909
+ (recent !== undefined && recent.has(candidate.echo));
910
+ if (!echoMatches) {
911
+ continue;
912
+ }
913
+ matchedFriendId = friendId;
914
+ matchedSession = session;
915
+ opened = candidate;
916
+ break;
917
+ }
918
+ if (!matchedFriendId || !matchedSession || !opened) {
919
+ this.#debugLog(`cookie response from ${remote.address}:${remote.port} ignored — no echo/key match`);
920
+ return;
921
+ }
922
+ if (!matchedSession.friendRealPublicKey) {
923
+ this.#debugLog(`cookie response from ${remote.address}:${remote.port} ignored — missing friend real key`);
924
+ return;
925
+ }
926
+ this.#debugLog(`cookie_recv friend=${matchedFriendId} from=${remote.address}:${remote.port} sending_handshake=1`);
927
+ // Successful cookie round-trip — drop backoff so any subsequent reconnect
928
+ // returns to the snappy 8s base cooldown instead of carrying forward
929
+ // however many unmatched attempts we accumulated while reaching this
930
+ // friend.
931
+ this.#cookieRetryCount.delete(matchedFriendId);
932
+ // Send our 0x1a CRYPTO_HS using the cookie peer just gave us
933
+ const handshake = createCryptoHandshake({
934
+ recipientCookie: opened.cookie,
935
+ baseNonce: matchedSession.ourBaseNonce,
936
+ sessionPublicKey: matchedSession.ourSessionKeyPair.publicKey,
937
+ senderRealSecretKey: this.#keyPair.secretKey,
938
+ senderRealPublicKey: this.#keyPair.publicKey,
939
+ senderDhtPublicKey: this.#keyPair.publicKey,
940
+ receiverRealPublicKey: matchedSession.friendRealPublicKey,
941
+ receiverDhtPublicKey: matchedSession.friendDhtPublicKey ?? matchedSession.friendRealPublicKey,
942
+ localCookieSymmetricKey: this.#cookieSymmetricKey
943
+ });
944
+ matchedSession.handshakeSentMs = Date.now();
945
+ matchedSession.pendingEcho = undefined;
946
+ matchedSession.pendingCookiePeerDhtPublicKey = undefined;
947
+ // Only update session.remote (a UDP endpoint) when the cookie
948
+ // response actually arrived via UDP. TCP-routed responses come
949
+ // with a synthetic remote like `tcp:<friendId>:0` and must not
950
+ // poison the UDP send-back path.
951
+ const fromTcp = this.#remoteIsTcp(remote);
952
+ if (!fromTcp) {
953
+ matchedSession.remote = { host: remote.address, port: remote.port };
954
+ }
955
+ else {
956
+ matchedSession.hasTcpRoute = true;
957
+ }
958
+ // Send the handshake reply via whichever transport the cookie
959
+ // response arrived on.
960
+ // For TCP: send via BOTH the routed DATA frame AND the OOB path.
961
+ // The DATA frame is the canonical channel but requires the relay's
962
+ // cid (CONNECTION_NOTIFICATION) to already be established. During
963
+ // simultaneous mutual initiation, the cid setup races against the
964
+ // handshake send — when it loses the race, the DATA frame is dropped
965
+ // and the session stalls. Sending OOB in parallel guarantees delivery
966
+ // even in that race, since handshake (0x1a) is a permitted OOB kind.
967
+ const sendHandshake = async () => {
968
+ if (fromTcp) {
969
+ let dataOk = false;
970
+ try {
971
+ await this.#sendToFriend(matchedFriendId, handshake);
972
+ dataOk = true;
973
+ }
974
+ catch {
975
+ // ignore — fall through to OOB
976
+ }
977
+ let oobSent = 0;
978
+ if (this.#tcpRelays && matchedSession.friendDhtPublicKey) {
979
+ oobSent = this.#tcpRelays.sendOobToFriend(matchedSession.friendDhtPublicKey, handshake);
980
+ }
981
+ if (!dataOk && oobSent === 0) {
982
+ throw new Error("handshake send failed via both data and oob paths");
983
+ }
984
+ return `tcp:${matchedFriendId}${dataOk ? " (data)" : ""}${oobSent > 0 ? ` (oob×${oobSent})` : ""}`;
985
+ }
986
+ await this.#sendPacket(handshake, matchedSession.remote);
987
+ return `${matchedSession.remote.host}:${matchedSession.remote.port}`;
988
+ };
989
+ void sendHandshake()
990
+ .then((dest) => {
991
+ this.#debugLog(`hs_sent friend=${matchedFriendId} to=${dest}`);
992
+ })
993
+ .catch((error) => {
994
+ this.#debugLog(`crypto handshake send failed: ${error.message}`);
995
+ });
996
+ return;
997
+ }
998
+ if (packet[0] === NET_PACKET_CRYPTO_HS && this.#cookieSymmetricKey) {
999
+ const hs = openCryptoHandshake(packet, {
1000
+ receiverRealSecretKey: this.#keyPair.secretKey,
1001
+ receiverCookieSymmetricKey: this.#cookieSymmetricKey
1002
+ });
1003
+ if (!hs) {
1004
+ this.#debugLog(`crypto handshake parse failed from ${remote.address}:${remote.port}`);
1005
+ return;
1006
+ }
1007
+ const friendId = carrierIdFromPublicKey(hs.senderRealPublicKey);
1008
+ let state = this.#friendSessions.get(friendId);
1009
+ // Detect whether we initiated: if we have a session in cookie_requested or handshake_sent state
1010
+ const weInitiated = state !== undefined && state.handshakeSentMs !== undefined;
1011
+ // Duplicate handshake fast path: if we already have an established
1012
+ // session with this peer AND the new handshake carries the same
1013
+ // peer session public key as the one we already accepted, this is
1014
+ // an in-flight retransmission from the peer (UDP loss). Refresh
1015
+ // the remote endpoint and lastPingRecvMs but DO NOT reset session
1016
+ // keys, base nonces, or packet counters — doing so would discard
1017
+ // any net_crypto state we built up after the first handshake and
1018
+ // make subsequent crypto data fail to decrypt on either side.
1019
+ if (state &&
1020
+ state.established &&
1021
+ state.peerSessionPublicKey &&
1022
+ bytesEqual(state.peerSessionPublicKey, hs.sessionPublicKey)) {
1023
+ if (this.#remoteIsTcp(remote)) {
1024
+ state.hasTcpRoute = true;
1025
+ }
1026
+ else {
1027
+ state.remote = { host: remote.address, port: remote.port };
1028
+ }
1029
+ state.lastPingRecvMs = Date.now();
1030
+ this.#debugVerboseLog(`hs_recv duplicate friend=${friendId} remote=${remote.address}:${remote.port} (session preserved)`);
1031
+ return;
1032
+ }
1033
+ if (!state) {
1034
+ state = {
1035
+ ourSessionKeyPair: createEphemeralKeyPair(),
1036
+ ourBaseNonce: randomBytes(24),
1037
+ friendRealPublicKey: hs.senderRealPublicKey,
1038
+ friendDhtPublicKey: hs.senderDhtPublicKey
1039
+ };
1040
+ this.#friendSessions.set(friendId, state);
1041
+ }
1042
+ else {
1043
+ if (!state.friendRealPublicKey)
1044
+ state.friendRealPublicKey = hs.senderRealPublicKey;
1045
+ if (!state.friendDhtPublicKey)
1046
+ state.friendDhtPublicKey = hs.senderDhtPublicKey;
1047
+ }
1048
+ const wasEstablished = state.established === true;
1049
+ // Detect a real session reset: the peer is offering NEW session
1050
+ // keys (different ephemeral session pubkey from before). When
1051
+ // that happens, BOTH sides start their packet-number counter
1052
+ // back at 0 — the encryption ties packet_number into the nonce
1053
+ // (sentNonce ⊕ packet_number low bytes), so if we keep our
1054
+ // counter from the previous session, our crypto data packets
1055
+ // arrive at iPad with a packet_number iPad's reset state
1056
+ // doesn't expect, and iPad drops them as out-of-window.
1057
+ // Observed in real iPad testing as "I see your `got: Hi` and
1058
+ // `got: Offline` but not your reply to my next message." —
1059
+ // the in-session sends after a session refresh were silently
1060
+ // rejected by iPad's net_crypto sliding-ack-window check.
1061
+ const prevPeerSessionPk = state.peerSessionPublicKey;
1062
+ const isNewSession = !prevPeerSessionPk || !bytesEqual(prevPeerSessionPk, hs.sessionPublicKey);
1063
+ // Reject handshakes proposing a different session pubkey while we
1064
+ // already have an established session. The cookie embedded in
1065
+ // every handshake is freshly issued (it's a response to that very
1066
+ // exchange's cookie REQUEST), so it isn't a reliable freshness
1067
+ // signal — and toxcore explicitly *doesn't* accept new keys while
1068
+ // ACCEPTED. The path back to a legitimate reinitiation is:
1069
+ // peer→stops sending ALIVE→we time out at 32s→delete session→next
1070
+ // handshake creates a fresh shell. That's slow but correct; the
1071
+ // alternative (blindly accepting) corrupts the current session
1072
+ // and silently breaks CRYPTO_DATA decryption (observed in
1073
+ // stability test as oscillating peerSessionPub and continuous
1074
+ // "crypto data received but no session matched").
1075
+ //
1076
+ // The retained `sessionEstablishedAtMs` is still useful for the
1077
+ // 1s grace below: very-fresh sessions can accept an in-flight
1078
+ // late handshake from a parallel cookie chain, since that's part
1079
+ // of normal symmetric-initiation convergence rather than a stale
1080
+ // replay.
1081
+ if (state.established &&
1082
+ isNewSession &&
1083
+ state.sessionEstablishedAtMs !== undefined) {
1084
+ const sinceEstablished = Date.now() - state.sessionEstablishedAtMs;
1085
+ if (sinceEstablished > 1000) {
1086
+ // Verbose only — peers retransmit handshakes aggressively, so
1087
+ // this line fires dozens of times per minute on a stuck pair
1088
+ // and drowns out signal. Visible with DECENT_DEBUG_VERBOSE=1.
1089
+ this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on established session, ${sinceEstablished}ms after establish)`);
1090
+ return;
1091
+ }
1092
+ }
1093
+ state.peerSessionPublicKey = hs.sessionPublicKey;
1094
+ state.peerBaseNonce = hs.baseNonce.slice();
1095
+ state.sessionSharedKey = nacl.box.before(hs.sessionPublicKey, state.ourSessionKeyPair.secretKey);
1096
+ state.established = true;
1097
+ state.sessionEstablishedAtMs = Date.now();
1098
+ if (this.#remoteIsTcp(remote)) {
1099
+ state.hasTcpRoute = true;
1100
+ // Don't set state.remote — there's no UDP endpoint for this peer.
1101
+ }
1102
+ else {
1103
+ state.remote = { host: remote.address, port: remote.port };
1104
+ }
1105
+ if (isNewSession) {
1106
+ state.sendPacketNumber = 0;
1107
+ state.receiveBufferStart = 0;
1108
+ }
1109
+ else {
1110
+ state.sendPacketNumber ??= 0;
1111
+ state.receiveBufferStart ??= 0;
1112
+ }
1113
+ state.lastPingRecvMs = Date.now();
1114
+ state.pendingEcho = undefined;
1115
+ state.pendingCookiePeerDhtPublicKey = undefined;
1116
+ this.#debugLog(`hs_recv friend=${friendId} initiated=${weInitiated ? 1 : 0} remote=${remote.address}:${remote.port}`);
1117
+ if (!wasEstablished) {
1118
+ this.#debugLog(`friend_connected friend=${friendId} remote=${remote.address}:${remote.port}`);
1119
+ }
1120
+ const friend = this.#friends.get(friendId);
1121
+ if (friend) {
1122
+ // Once we've established a real session with this peer, they are
1123
+ // "accepted" forever — even if the session later drops. Without
1124
+ // this `acceptedAt` mark, every restart of the demo with the same
1125
+ // DECENT_FRIEND_ADDRESS would re-fire `sendFriendRequest`, since
1126
+ // `#recordOutgoingFriendRequest` only suppresses dupes when the
1127
+ // record has `status === "online"` (transient) OR `acceptedAt`
1128
+ // (persistent). iOS would see the duplicate friend request as a
1129
+ // "friend changed nospam" / re-add prompt; we want it silent.
1130
+ this.#friends.set(friendId, {
1131
+ ...friend,
1132
+ status: "online",
1133
+ acceptedAt: friend.acceptedAt ?? Date.now(),
1134
+ remoteHost: remote.address,
1135
+ remotePort: remote.port
1136
+ });
1137
+ this.#persistFriends();
1138
+ }
1139
+ // Only emit the friendConnection: connected event on the first
1140
+ // transition from disconnected -> connected. Re-firing it on each
1141
+ // retransmitted handshake produces noisy "friend connection ...
1142
+ // connected" lines in the user's app callbacks.
1143
+ if (!wasEstablished) {
1144
+ this.#events.emit("friendConnection", {
1145
+ pubkey: friendId,
1146
+ status: "connected"
1147
+ });
1148
+ }
1149
+ // Only send a reply handshake when the peer initiated.
1150
+ // Otherwise we already sent ours during the cookie response handler.
1151
+ if (!weInitiated) {
1152
+ const reply = createCryptoHandshake({
1153
+ recipientCookie: hs.embeddedCookie,
1154
+ baseNonce: state.ourBaseNonce,
1155
+ sessionPublicKey: state.ourSessionKeyPair.publicKey,
1156
+ senderRealSecretKey: this.#keyPair.secretKey,
1157
+ senderRealPublicKey: this.#keyPair.publicKey,
1158
+ senderDhtPublicKey: this.#keyPair.publicKey,
1159
+ receiverRealPublicKey: hs.senderRealPublicKey,
1160
+ receiverDhtPublicKey: hs.senderDhtPublicKey,
1161
+ localCookieSymmetricKey: this.#cookieSymmetricKey
1162
+ });
1163
+ const replyFromTcp = this.#remoteIsTcp(remote);
1164
+ const sendReply = () => {
1165
+ if (replyFromTcp) {
1166
+ // OOB reply must go to the DHT pubkey (relay routing key),
1167
+ // not the real pubkey — see the cookie response handler
1168
+ // above for the full explanation.
1169
+ const sent = this.#tcpRelays?.sendOobToFriend(hs.senderDhtPublicKey, reply) ?? 0;
1170
+ return Promise.resolve(sent);
1171
+ }
1172
+ return this.#sendPacket(reply, { host: remote.address, port: remote.port });
1173
+ };
1174
+ void sendReply()
1175
+ .then(() => {
1176
+ this.#debugLog(`crypto handshake reply sent to ${friendId}${replyFromTcp ? " (tcp)" : ""}`);
1177
+ void this.#sendMessengerPacket(friendId, PACKET_ID_ONLINE, new Uint8Array()).catch((error) => {
1178
+ this.#debugLog(`send online packet failed: ${error.message}`);
1179
+ });
1180
+ })
1181
+ .catch((error) => {
1182
+ this.#debugLog(`crypto handshake reply failed: ${error.message}`);
1183
+ });
1184
+ }
1185
+ else {
1186
+ // We initiated and just received the peer's reply. Send PACKET_ID_ONLINE now.
1187
+ void this.#sendMessengerPacket(friendId, PACKET_ID_ONLINE, new Uint8Array()).catch((error) => {
1188
+ this.#debugLog(`send online packet failed: ${error.message}`);
1189
+ });
1190
+ }
1191
+ return;
1192
+ }
1193
+ if (packet[0] === NET_PACKET_CRYPTO_DATA) {
1194
+ for (const [friendId, state] of this.#friendSessions.entries()) {
1195
+ if (!state.sessionSharedKey || !state.peerBaseNonce) {
1196
+ continue;
1197
+ }
1198
+ const opened = openCryptoDataPacket(packet, {
1199
+ sessionSharedKey: state.sessionSharedKey,
1200
+ recvBaseNonce: state.peerBaseNonce
1201
+ });
1202
+ if (!opened) {
1203
+ continue;
1204
+ }
1205
+ state.peerBaseNonce[state.peerBaseNonce.length - 2] = packet[1];
1206
+ state.peerBaseNonce[state.peerBaseNonce.length - 1] = packet[2];
1207
+ incrementNonce(state.peerBaseNonce);
1208
+ state.lastPingRecvMs = Date.now();
1209
+ // Same TCP-vs-UDP rule as the handshake handlers: don't poison
1210
+ // the UDP send-back endpoint when this packet came in via TCP.
1211
+ if (this.#remoteIsTcp(remote)) {
1212
+ state.hasTcpRoute = true;
1213
+ }
1214
+ else {
1215
+ state.remote = { host: remote.address, port: remote.port };
1216
+ }
1217
+ state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
1218
+ const kind = opened.payload[0];
1219
+ const inner = opened.payload.slice(1);
1220
+ if (kind === PACKET_ID_ONLINE) {
1221
+ this.#setFriendOnline(friendId, remote.address, remote.port);
1222
+ this.#debugLog(`crypto online packet received from ${friendId}`);
1223
+ // First time the friend signals ONLINE, push our profile
1224
+ // (nickname + status message) and any configured greeting so the
1225
+ // other side updates "unknown" to a real name. Toxcore semantics:
1226
+ // these are sent on every reconnect by the friend_connection layer.
1227
+ this.#sendProfileAndGreeting(friendId);
1228
+ return;
1229
+ }
1230
+ if (kind === PACKET_ID_OFFLINE) {
1231
+ this.#setFriendOffline(friendId);
1232
+ this.#debugLog(`crypto offline packet received from ${friendId}`);
1233
+ return;
1234
+ }
1235
+ if (kind === PACKET_ID_KILL) {
1236
+ this.#friendSessions.delete(friendId);
1237
+ this.#setFriendOffline(friendId);
1238
+ this.#debugLog(`crypto kill received from ${friendId}, session torn down`);
1239
+ return;
1240
+ }
1241
+ if (kind === PACKET_ID_ALIVE) {
1242
+ // Pure liveness signal; lastPingRecvMs already updated above.
1243
+ this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
1244
+ return;
1245
+ }
1246
+ if (kind === PACKET_ID_REQUEST) {
1247
+ // Lossless retransmission request from peer. Full reliable stream
1248
+ // not yet implemented in JS port, so just acknowledge by logging.
1249
+ this.#debugVerboseLog(`crypto request packet received from ${friendId} (no retransmit queue)`);
1250
+ return;
1251
+ }
1252
+ if (kind === PACKET_ID_SHARE_RELAYS) {
1253
+ // Peer is sharing TCP relay endpoints they're connected to. We
1254
+ // don't have a TCP relay client yet, so cache nothing — just log.
1255
+ this.#debugVerboseLog(`crypto share-relays from ${friendId} (TCP relay client not implemented)`);
1256
+ return;
1257
+ }
1258
+ if (kind === PACKET_ID_NICKNAME) {
1259
+ const name = decodeUtf8Best(inner);
1260
+ const friend = this.#friends.get(friendId);
1261
+ if (friend && name && friend.name !== name) {
1262
+ this.#friends.set(friendId, { ...friend, name });
1263
+ this.#persistFriends();
1264
+ this.#events.emit("friendInfo", {
1265
+ pubkey: friendId,
1266
+ userid: friend.userid ?? friendId,
1267
+ name,
1268
+ description: friend.description
1269
+ });
1270
+ }
1271
+ this.#debugLog(`crypto nickname received from ${friendId}: "${name}"`);
1272
+ return;
1273
+ }
1274
+ if (kind === PACKET_ID_STATUSMESSAGE) {
1275
+ // Carrier C SDK wraps the user-profile in a FlatBuffers
1276
+ // PACKET_TYPE_USERINFO packet here (name + descr + gender + phone
1277
+ // + email + region + has_avatar). Some non-Carrier toxcore peers
1278
+ // may still send raw UTF-8, so try the FB decode first and fall
1279
+ // back to plain UTF-8 for compatibility.
1280
+ let userInfoName;
1281
+ let userInfoDescr;
1282
+ try {
1283
+ const decoded = decodeCarrierPacket(inner);
1284
+ if (decoded.type === PACKET_TYPE_USERINFO) {
1285
+ userInfoName = decoded.name;
1286
+ userInfoDescr = decoded.descr;
1287
+ }
1288
+ }
1289
+ catch {
1290
+ // Not a Carrier userinfo packet — fall through.
1291
+ }
1292
+ if (userInfoName !== undefined || userInfoDescr !== undefined) {
1293
+ const friend = this.#friends.get(friendId);
1294
+ const newName = userInfoName && userInfoName.length > 0 ? userInfoName : friend?.name;
1295
+ const newDescr = userInfoDescr ?? friend?.description;
1296
+ if (friend && (friend.name !== newName || friend.description !== newDescr)) {
1297
+ this.#friends.set(friendId, { ...friend, name: newName, description: newDescr });
1298
+ this.#persistFriends();
1299
+ this.#events.emit("friendInfo", {
1300
+ pubkey: friendId,
1301
+ userid: friend.userid ?? friendId,
1302
+ name: newName,
1303
+ description: newDescr
1304
+ });
1305
+ }
1306
+ this.#debugLog(`crypto userinfo received from ${friendId}: name="${userInfoName ?? ""}" descr="${userInfoDescr ?? ""}"`);
1307
+ }
1308
+ else {
1309
+ const status = decodeUtf8Best(inner);
1310
+ const friend = this.#friends.get(friendId);
1311
+ if (friend && status && friend.description !== status) {
1312
+ this.#friends.set(friendId, { ...friend, description: status });
1313
+ this.#persistFriends();
1314
+ }
1315
+ this.#debugVerboseLog(`crypto status message (raw) received from ${friendId}: "${status}"`);
1316
+ }
1317
+ return;
1318
+ }
1319
+ if (kind === PACKET_ID_USERSTATUS) {
1320
+ this.#debugVerboseLog(`crypto user-status received from ${friendId} (${inner[0] ?? "?"})`);
1321
+ return;
1322
+ }
1323
+ if (kind === PACKET_ID_TYPING) {
1324
+ this.#debugVerboseLog(`crypto typing received from ${friendId} (${inner[0] ?? "?"})`);
1325
+ return;
1326
+ }
1327
+ if (kind === PACKET_ID_MESSAGE || kind === PACKET_ID_ACTION) {
1328
+ // Plain text inside (no Carrier FlatBuffers wrapper). Some peers
1329
+ // send raw UTF-8, others send a Carrier message packet — try both.
1330
+ let text = tryDecodeCarrierMessagePacket(inner) ?? decodeUtf8Best(inner);
1331
+ if (!text) {
1332
+ this.#debugLog(`crypto message packet decode failed from ${friendId}`);
1333
+ return;
1334
+ }
1335
+ this.#setFriendOnline(friendId, remote.address, remote.port);
1336
+ this.#events.emit("text", {
1337
+ pubkey: friendId,
1338
+ text
1339
+ });
1340
+ this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
1341
+ return;
1342
+ }
1343
+ this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
1344
+ return;
1345
+ }
1346
+ this.#debugLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
1347
+ return;
1348
+ }
1349
+ if (packet[0] === NET_PACKET_ONION_DATA_RESPONSE && this.#announceDataKey) {
1350
+ this.#debugLog("onion data response received");
1351
+ const routed = openOnionDataResponse(packet, {
1352
+ dataSecretKey: this.#announceDataKey.secretKey
1353
+ });
1354
+ if (!routed) {
1355
+ this.#debugLog("onion data response decrypt failed");
1356
+ return;
1357
+ }
1358
+ const onionData = openOnionDataPacket(routed.payload, {
1359
+ receiverSecretKey: this.#keyPair.secretKey,
1360
+ nonce: routed.nonce
1361
+ });
1362
+ if (!onionData) {
1363
+ this.#debugLog("onion friend request payload decrypt failed");
1364
+ return;
1365
+ }
1366
+ if (onionData.innerPacketId !== ONION_FRIEND_REQUEST_ID) {
1367
+ if (onionData.innerPacketId === CRYPTO_PACKET_DHTPK) {
1368
+ this.#handleOnionDhtPk(onionData.senderPublicKey, onionData.innerPayload);
1369
+ return;
1370
+ }
1371
+ const previewLen = Math.min(48, onionData.innerPayload.length);
1372
+ const previewHex = Buffer.from(onionData.innerPayload.slice(0, previewLen)).toString("hex");
1373
+ const senderId = carrierIdFromPublicKey(onionData.senderPublicKey);
1374
+ this.#debugLog(`onion data response ignored innerPacketId=${onionData.innerPacketId} ` +
1375
+ `sender=${senderId} payloadLen=${onionData.innerPayload.length} ` +
1376
+ `payloadPreviewHex=${previewHex}`);
1377
+ return;
1378
+ }
1379
+ const friendRequest = splitFriendRequestPayload(onionData.innerPayload);
1380
+ if (!friendRequest) {
1381
+ this.#debugLog("onion friend request payload decode failed");
1382
+ return;
1383
+ }
1384
+ this.#emitFriendRequest(onionData.senderPublicKey, friendRequest.carrierPacket, friendRequest.nospam);
1385
+ }
1386
+ };
1387
+ #handleOnionDhtPk(senderPublicKey, payload) {
1388
+ const senderId = carrierIdFromPublicKey(senderPublicKey);
1389
+ if (payload.length < 8 + 32) {
1390
+ this.#debugLog(`onion dhtpk payload too short from ${senderId}: len=${payload.length}`);
1391
+ return;
1392
+ }
1393
+ const noReplay = readUint64BE(payload, 0);
1394
+ const friendDhtPublicKey = payload.slice(8, 40);
1395
+ const extra = payload.slice(40);
1396
+ const extraPreview = Buffer.from(extra.slice(0, Math.min(48, extra.length))).toString("hex");
1397
+ let extraNodes = parsePackedNodes(extra);
1398
+ // Some tox-compatible peers include encrypted endpoint hints in the DHTPK
1399
+ // payload tail: nonce (24 bytes) + crypto_box ciphertext.
1400
+ if (extraNodes.length === 0 && this.#keyPair && extra.length > 24 + 16) {
1401
+ const nonce = extra.slice(0, 24);
1402
+ const encrypted = extra.slice(24);
1403
+ const openedBySender = nacl.box.open(encrypted, nonce, senderPublicKey, this.#keyPair.secretKey);
1404
+ if (openedBySender) {
1405
+ const decryptedNodes = parsePackedNodes(openedBySender);
1406
+ if (decryptedNodes.length > 0) {
1407
+ extraNodes = decryptedNodes;
1408
+ this.#debugLog(`onion dhtpk decrypted extra using sender key; nodes=${decryptedNodes.length}`);
1409
+ }
1410
+ }
1411
+ if (extraNodes.length === 0) {
1412
+ const openedByDhtPk = nacl.box.open(encrypted, nonce, friendDhtPublicKey, this.#keyPair.secretKey);
1413
+ if (openedByDhtPk) {
1414
+ const decryptedNodes = parsePackedNodes(openedByDhtPk);
1415
+ if (decryptedNodes.length > 0) {
1416
+ extraNodes = decryptedNodes;
1417
+ this.#debugLog(`onion dhtpk decrypted extra using dht key; nodes=${decryptedNodes.length}`);
1418
+ }
1419
+ }
1420
+ }
1421
+ }
1422
+ // Treat DHTPK as a liveness/accept signal from existing friend and use any
1423
+ // endpoint hints in the payload to attempt net_crypto session bring-up.
1424
+ const friend = this.#friends.get(senderId);
1425
+ if (!friend) {
1426
+ return;
1427
+ }
1428
+ let session = this.#friendSessions.get(senderId);
1429
+ if (!session) {
1430
+ session = {
1431
+ ourSessionKeyPair: createEphemeralKeyPair(),
1432
+ ourBaseNonce: randomBytes(24)
1433
+ };
1434
+ this.#friendSessions.set(senderId, session);
1435
+ }
1436
+ if (session.lastDhtPkNoReplay !== undefined && noReplay <= session.lastDhtPkNoReplay) {
1437
+ this.#debugLog(`onion dhtpk replay/old packet ignored from ${senderId} noReplay=${noReplay.toString()}`);
1438
+ return;
1439
+ }
1440
+ session.lastDhtPkNoReplay = noReplay;
1441
+ session.friendRealPublicKey ??= senderPublicKey;
1442
+ session.friendDhtPublicKey = friendDhtPublicKey;
1443
+ // A DHT-PK announce from this friend IS the proof they've accepted
1444
+ // our friend request — only friends actively trying to reach us send
1445
+ // these. Mark acceptedAt so subsequent peer.sendFriendRequest calls
1446
+ // are no-ops (otherwise the demo's retry loop keeps re-POSTing the
1447
+ // request to express, which iOS Beagle re-displays as a new
1448
+ // "pending" notification each time, even though the user has
1449
+ // already accepted on their device).
1450
+ if (friend.status === "requested" && !friend.acceptedAt) {
1451
+ this.#friends.set(senderId, {
1452
+ ...friend,
1453
+ status: "offline",
1454
+ acceptedAt: Date.now()
1455
+ });
1456
+ this.#persistFriends();
1457
+ this.#debugLog(`friend ${senderId} accepted (proof: dhtpk_update); will stop re-sending friend request`);
1458
+ }
1459
+ const friendDhtId = carrierIdFromPublicKey(friendDhtPublicKey);
1460
+ const knownMatch = this.#knownNodes.find((node) => node.pk === friendDhtId || node.pk === senderId);
1461
+ if (knownMatch) {
1462
+ this.#cacheFriendRemote(senderId, knownMatch.host, knownMatch.port, senderPublicKey, friendDhtPublicKey);
1463
+ this.#debugLog(`onion dhtpk matched known node for ${senderId} at ${knownMatch.host}:${knownMatch.port}`);
1464
+ }
1465
+ this.#debugLog(`dhtpk_update friend=${senderId} noReplay=${noReplay.toString()} ` +
1466
+ `dhtpk=${carrierIdFromPublicKey(friendDhtPublicKey)} extraLen=${extra.length} ` +
1467
+ `extraNodes=${extraNodes.length} extraPreviewHex=${extraPreview}`);
1468
+ if (extraNodes.length > 0) {
1469
+ for (const candidate of extraNodes) {
1470
+ if (candidate.isTcp) {
1471
+ // iPad/iOS Beagle peers advertise their reachable TCP relays
1472
+ // here (every entry has family = 0x82). Add each to our pool
1473
+ // so we end up on shared relays — without this, even though
1474
+ // both sides are willing to use TCP, we'd never overlap and
1475
+ // sessions would never come up via the relay path.
1476
+ if (this.#tcpRelays) {
1477
+ this.#tcpRelays.addRelay(candidate);
1478
+ // ROUTING_REQUEST must use the friend's DHT pubkey (= the
1479
+ // pubkey they handshook the relay with), NOT their real
1480
+ // friend identity pubkey. The relay's connection table is
1481
+ // keyed by handshake pubkey. iOS Beagle uses different
1482
+ // keys for DHT vs friend identity (real "7se1FY..." vs
1483
+ // DHT "GgAxwFFP...") so requesting route for the real
1484
+ // pubkey would silently mismatch and the relay would never
1485
+ // link us. Friend's DHT pubkey is in the inbound announce
1486
+ // payload (already parsed above as friendDhtPublicKey).
1487
+ this.#tcpRelays.requestRoute(friendDhtPublicKey);
1488
+ }
1489
+ }
1490
+ else {
1491
+ this.#cacheFriendRemote(senderId, candidate.host, candidate.port, senderPublicKey, friendDhtPublicKey);
1492
+ }
1493
+ }
1494
+ }
1495
+ void this.#initiateSession(senderId).catch((error) => {
1496
+ this.#debugLog(`onion dhtpk initiate session failed for ${senderId}: ${error.message}`);
1497
+ });
1498
+ void this.#discoverAndCacheFriendEndpoint(senderId, friendDhtPublicKey).then((found) => {
1499
+ if (!found) {
1500
+ return this.#discoverAndCacheFriendEndpoint(senderId, senderPublicKey);
1501
+ }
1502
+ return Promise.resolve(true);
1503
+ }).then((found) => {
1504
+ if (!found) {
1505
+ return;
1506
+ }
1507
+ void this.#initiateSession(senderId).catch((error) => {
1508
+ this.#debugLog(`friend endpoint discovery initiate failed for ${senderId}: ${error.message}`);
1509
+ });
1510
+ }).catch((error) => {
1511
+ this.#debugLog(`friend endpoint discovery failed for ${senderId}: ${error.message}`);
1512
+ });
1513
+ }
1514
+ #emitFriendRequest(senderPublicKey, carrierPacket, nospam) {
1515
+ let hello = "";
1516
+ let name = "";
1517
+ let description = "";
1518
+ try {
1519
+ const decoded = decodeCarrierPacket(carrierPacket);
1520
+ if (decoded.type !== PACKET_TYPE_FRIEND_REQUEST) {
1521
+ return;
1522
+ }
1523
+ hello = decoded.hello;
1524
+ name = decoded.name;
1525
+ description = decoded.descr;
1526
+ }
1527
+ catch {
1528
+ return;
1529
+ }
1530
+ const userid = carrierIdFromPublicKey(senderPublicKey);
1531
+ // If we already have this peer as a friend, do not re-emit a friend
1532
+ // request to the UI. Instead, treat the packet as a reconnection signal
1533
+ // and ensure we attempt to re-establish a net_crypto session.
1534
+ if (this.#friends.has(userid)) {
1535
+ this.#debugLog(`friend request from existing friend ${userid} treated as reconnection signal`);
1536
+ void this.#initiateSession(userid).catch((error) => {
1537
+ this.#debugLog(`reconnect initiate session failed for ${userid}: ${error.message}`);
1538
+ });
1539
+ return;
1540
+ }
1541
+ if (this.#pendingFriendRequests.has(userid)) {
1542
+ return;
1543
+ }
1544
+ const request = {
1545
+ pubkey: userid,
1546
+ userid,
1547
+ nospam,
1548
+ address: carrierAddressFromPublicKey(senderPublicKey, nospam),
1549
+ name,
1550
+ description,
1551
+ hello
1552
+ };
1553
+ this.#pendingFriendRequests.set(userid, request);
1554
+ this.#events.emit("friendInfo", {
1555
+ pubkey: userid,
1556
+ userid,
1557
+ name,
1558
+ description
1559
+ });
1560
+ this.#events.emit("friendRequest", request);
1561
+ }
1562
+ #emitOfflineFriendRequest(fromUserId, packet) {
1563
+ let helloText = "";
1564
+ let name = "";
1565
+ let description = "";
1566
+ try {
1567
+ const decoded = decodeCarrierPacket(packet);
1568
+ if (decoded.type !== PACKET_TYPE_FRIEND_REQUEST) {
1569
+ return;
1570
+ }
1571
+ helloText = decoded.hello;
1572
+ name = decoded.name;
1573
+ description = decoded.descr;
1574
+ }
1575
+ catch {
1576
+ return;
1577
+ }
1578
+ if (this.#pendingFriendRequests.has(fromUserId)) {
1579
+ return;
1580
+ }
1581
+ const request = {
1582
+ pubkey: fromUserId,
1583
+ userid: fromUserId,
1584
+ name,
1585
+ description,
1586
+ hello: helloText
1587
+ };
1588
+ this.#pendingFriendRequests.set(fromUserId, request);
1589
+ this.#events.emit("friendInfo", {
1590
+ pubkey: fromUserId,
1591
+ userid: fromUserId,
1592
+ name,
1593
+ description
1594
+ });
1595
+ this.#events.emit("friendRequest", request);
1596
+ }
1597
+ #emitOfflineFriendMessage(fromUserId, packet) {
1598
+ try {
1599
+ const decoded = decodeCarrierPacket(packet);
1600
+ if (decoded.type !== PACKET_TYPE_MESSAGE) {
1601
+ return;
1602
+ }
1603
+ const text = new TextDecoder().decode(decoded.data);
1604
+ // Implicit-acceptance handling: in toxcore there's no explicit
1605
+ // "friend accept" packet. When peer A sends a friend request to
1606
+ // peer B and B accepts, B silently adds A as a friend and tries to
1607
+ // bring up a net_crypto session. If A is offline at that moment,
1608
+ // B's first messages are queued by B's client and routed through
1609
+ // the express offline relay. The first such message arriving at A
1610
+ // *is* the proof that B accepted.
1611
+ //
1612
+ // Treat any offline message from a friend currently in "requested"
1613
+ // state as implicit acceptance: stamp acceptedAt so we won't
1614
+ // re-issue the friend request next run, demote to "offline" so
1615
+ // the friend connection loop will start trying to establish a
1616
+ // direct session, and immediately kick #sendOnionDhtPk so B
1617
+ // learns our current endpoint.
1618
+ const friend = this.#friends.get(fromUserId);
1619
+ if (friend && friend.status === "requested" && !friend.acceptedAt) {
1620
+ this.#friends.set(fromUserId, {
1621
+ ...friend,
1622
+ status: "offline",
1623
+ acceptedAt: Date.now()
1624
+ });
1625
+ this.#persistFriends();
1626
+ this.#debugLog(`offline message from ${fromUserId} treated as implicit accept of our friend request; ` +
1627
+ `friend is now accepted, queueing DHT-PK announce`);
1628
+ // Best-effort: try to send our DHT-PK to B so they can find our
1629
+ // current UDP endpoint and switch from express to direct.
1630
+ try {
1631
+ const realPk = base58ToBytes(fromUserId);
1632
+ if (realPk.length === 32) {
1633
+ this.#dhtPkSendCooldown.delete(fromUserId);
1634
+ void this.#sendOnionDhtPk(realPk).catch(() => { });
1635
+ }
1636
+ }
1637
+ catch {
1638
+ // Malformed pubkey, fall through.
1639
+ }
1640
+ }
1641
+ this.#events.emit("text", { pubkey: fromUserId, text });
1642
+ }
1643
+ catch {
1644
+ // Ignore invalid offline payloads.
1645
+ }
1646
+ }
1647
+ async #discoverFriendRoutes(friendPublicKey) {
1648
+ if (!this.#keyPair) {
1649
+ throw new Error("Peer is not started");
1650
+ }
1651
+ const searchKey = createEphemeralKeyPair();
1652
+ const routes = [];
1653
+ const routeSeen = new Set();
1654
+ const queue = dedupeNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes)
1655
+ .filter((n) => !this.#isNodeBlacklisted(`${n.host}:${n.port}`))
1656
+ .sort((a, b) => this.#nodeScore(`${b.host}:${b.port}`) - this.#nodeScore(`${a.host}:${a.port}`));
1657
+ const friendId = carrierIdFromPublicKey(friendPublicKey);
1658
+ const visited = new Set();
1659
+ let attempts = 0;
1660
+ const maxAttempts = MAX_FRIEND_ROUTE_ATTEMPTS;
1661
+ // Drive the walk in waves: pick up to FRIEND_ROUTE_BATCH_SIZE candidates
1662
+ // from the queue, fire announce step1 to all of them in parallel, then
1663
+ // process the responses together (extracting routes + new candidates
1664
+ // for the next wave). Mirrors toxcore's onion_client.c which keeps up
1665
+ // to MAX_ONION_CLIENTS=8 in-flight requests at once instead of a
1666
+ // strict sequential walk. Net effect on a typical ISP: full discovery
1667
+ // drops from ~60s (12 × 5s sequential timeouts) to ~10s (one or two
1668
+ // 4-10s parallel waves).
1669
+ while (queue.length > 0 && attempts < maxAttempts && routes.length < 8) {
1670
+ const batch = [];
1671
+ while (queue.length > 0 && batch.length < FRIEND_ROUTE_BATCH_SIZE && attempts < maxAttempts) {
1672
+ const node = queue.shift();
1673
+ const nodeId = `${node.host}:${node.port}`;
1674
+ if (visited.has(nodeId) || !node.pk) {
1675
+ continue;
1676
+ }
1677
+ visited.add(nodeId);
1678
+ let nodePk;
1679
+ try {
1680
+ nodePk = base58ToBytes(node.pk);
1681
+ }
1682
+ catch {
1683
+ continue;
1684
+ }
1685
+ if (nodePk.length !== 32) {
1686
+ continue;
1687
+ }
1688
+ attempts += 1;
1689
+ batch.push({ node, nodePk, sendBack: randomBytes(8) });
1690
+ }
1691
+ if (batch.length === 0) {
1692
+ break;
1693
+ }
1694
+ const zeroPing = new Uint8Array(32);
1695
+ const settled = await Promise.allSettled(batch.map((c) => this.#sendAnnounceAndWait({
1696
+ node: c.node,
1697
+ nodePublicKey: c.nodePk,
1698
+ senderPublicKey: searchKey.publicKey,
1699
+ senderSecretKey: searchKey.secretKey,
1700
+ pingId: zeroPing,
1701
+ searchPublicKey: friendPublicKey,
1702
+ dataPublicKey: new Uint8Array(32),
1703
+ sendBack: c.sendBack,
1704
+ allowDirectFallback: true,
1705
+ attempts: FRIEND_ANNOUNCE_ATTEMPTS
1706
+ })));
1707
+ for (let i = 0; i < batch.length; i++) {
1708
+ const candidate = batch[i];
1709
+ const result = settled[i];
1710
+ const nodeId = `${candidate.node.host}:${candidate.node.port}`;
1711
+ const response1 = result.status === "fulfilled" ? result.value : undefined;
1712
+ if (!response1) {
1713
+ this.#debugLog(`announce step1 no response from ${nodeId}`);
1714
+ continue;
1715
+ }
1716
+ this.#debugLog(`announce step1 response from ${nodeId} isStored=${response1.isStored}`);
1717
+ if (response1.isStored === 1) {
1718
+ const routeKey = `${nodeId}:${Buffer.from(response1.pingOrDataPublicKey).toString("hex")}`;
1719
+ if (!routeSeen.has(routeKey)) {
1720
+ routeSeen.add(routeKey);
1721
+ routes.push({
1722
+ node: candidate.node,
1723
+ routePublicKey: response1.pingOrDataPublicKey
1724
+ });
1725
+ this.#debugLog(`route discovered via ${nodeId}`);
1726
+ }
1727
+ }
1728
+ const discovered = parsePackedNodes(response1.nodes);
1729
+ if (discovered.length > 0) {
1730
+ this.#knownNodes = dedupeNodes([...this.#knownNodes, ...discovered]);
1731
+ for (const discoveredNode of discovered) {
1732
+ if (discoveredNode.pk === friendId) {
1733
+ this.#cacheFriendRemote(friendId, discoveredNode.host, discoveredNode.port, friendPublicKey, friendPublicKey);
1734
+ }
1735
+ const discoveredId = `${discoveredNode.host}:${discoveredNode.port}`;
1736
+ if (!visited.has(discoveredId) && !this.#isNodeBlacklisted(discoveredId)) {
1737
+ queue.push(discoveredNode);
1738
+ }
1739
+ }
1740
+ }
1741
+ }
1742
+ }
1743
+ return routes;
1744
+ }
1745
+ async #discoverAndCacheFriendEndpoint(friendId, searchPublicKey) {
1746
+ if (!this.#keyPair) {
1747
+ return false;
1748
+ }
1749
+ const targetId = carrierIdFromPublicKey(searchPublicKey);
1750
+ const queue = dedupeNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes)
1751
+ .filter((n) => !this.#isNodeBlacklisted(`${n.host}:${n.port}`))
1752
+ .sort((a, b) => this.#nodeScore(`${b.host}:${b.port}`) - this.#nodeScore(`${a.host}:${a.port}`))
1753
+ .slice(0, Math.max(8, Math.min(24, MAX_FRIEND_ROUTE_ATTEMPTS)));
1754
+ const directKnown = queue.find((node) => node.pk === targetId);
1755
+ if (directKnown) {
1756
+ this.#cacheFriendRemote(friendId, directKnown.host, directKnown.port);
1757
+ this.#debugLog(`friend endpoint matched known node for ${friendId} at ${directKnown.host}:${directKnown.port}`);
1758
+ return true;
1759
+ }
1760
+ const visited = new Set();
1761
+ for (const node of queue) {
1762
+ const nodeId = `${node.host}:${node.port}`;
1763
+ if (visited.has(nodeId) || !node.pk) {
1764
+ continue;
1765
+ }
1766
+ visited.add(nodeId);
1767
+ let nodePk;
1768
+ try {
1769
+ nodePk = base58ToBytes(node.pk);
1770
+ }
1771
+ catch {
1772
+ continue;
1773
+ }
1774
+ if (nodePk.length !== 32) {
1775
+ continue;
1776
+ }
1777
+ const sendBack = randomBytes(8);
1778
+ const response1 = await this.#sendAnnounceAndWait({
1779
+ node,
1780
+ nodePublicKey: nodePk,
1781
+ senderPublicKey: this.#keyPair.publicKey,
1782
+ senderSecretKey: this.#keyPair.secretKey,
1783
+ pingId: new Uint8Array(32),
1784
+ searchPublicKey,
1785
+ dataPublicKey: new Uint8Array(32),
1786
+ sendBack,
1787
+ allowDirectFallback: true,
1788
+ attempts: 1
1789
+ });
1790
+ if (!response1) {
1791
+ continue;
1792
+ }
1793
+ const response2 = await this.#sendAnnounceAndWait({
1794
+ node,
1795
+ nodePublicKey: nodePk,
1796
+ senderPublicKey: this.#keyPair.publicKey,
1797
+ senderSecretKey: this.#keyPair.secretKey,
1798
+ pingId: response1.pingOrDataPublicKey,
1799
+ searchPublicKey,
1800
+ dataPublicKey: new Uint8Array(32),
1801
+ sendBack,
1802
+ allowDirectFallback: true,
1803
+ attempts: 1
1804
+ });
1805
+ const responses = response2 ? [response1, response2] : [response1];
1806
+ const discovered = responses.flatMap((resp) => parsePackedNodes(resp.nodes));
1807
+ if (discovered.length > 0) {
1808
+ this.#knownNodes = dedupeNodes([...this.#knownNodes, ...discovered]);
1809
+ }
1810
+ const exact = discovered.find((n) => n.pk === targetId);
1811
+ if (exact) {
1812
+ this.#cacheFriendRemote(friendId, exact.host, exact.port);
1813
+ this.#debugLog(`friend endpoint discovered for ${friendId} at ${exact.host}:${exact.port}`);
1814
+ return true;
1815
+ }
1816
+ }
1817
+ return false;
1818
+ }
1819
+ async #announceSelfBestEffort(force = false, deadlineMs = Number.POSITIVE_INFINITY) {
1820
+ if (!this.#keyPair || !this.#announceDataKey) {
1821
+ return [];
1822
+ }
1823
+ const now = Date.now();
1824
+ if (!force && now - this.#lastSelfAnnounceMs < 12000) {
1825
+ return [];
1826
+ }
1827
+ this.#lastSelfAnnounceMs = now;
1828
+ const storedNodes = [];
1829
+ const targets = dedupeNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes)
1830
+ .filter((n) => !this.#isNodeBlacklisted(`${n.host}:${n.port}`))
1831
+ .sort((a, b) => this.#nodeScore(`${b.host}:${b.port}`) - this.#nodeScore(`${a.host}:${a.port}`))
1832
+ .slice(0, MAX_SELF_ANNOUNCE_TARGETS);
1833
+ const zeroPing = new Uint8Array(32);
1834
+ const candidates = [];
1835
+ for (const node of targets) {
1836
+ if (!node.pk)
1837
+ continue;
1838
+ let nodePk;
1839
+ try {
1840
+ nodePk = base58ToBytes(node.pk);
1841
+ }
1842
+ catch {
1843
+ continue;
1844
+ }
1845
+ if (nodePk.length !== 32)
1846
+ continue;
1847
+ candidates.push({ node, nodePk, sendBack: randomBytes(8) });
1848
+ }
1849
+ // Walk in waves — fire SELF_ANNOUNCE_BATCH_SIZE step1+step2 sequences
1850
+ // in parallel per wave, then process. Toxcore's onion_client.c does
1851
+ // up to MAX_ONION_CLIENTS_ANNOUNCE = 12 simultaneously, which lets it
1852
+ // stabilize self-announce in ~10s on a healthy network. Old serial
1853
+ // walk took (per-node-timeout × N targets × 2 steps) ≈ 16 × 5s × 2
1854
+ // = 160s worst case before a single isStored=2 response.
1855
+ for (let i = 0; i < candidates.length; i += SELF_ANNOUNCE_BATCH_SIZE) {
1856
+ if (Date.now() >= deadlineMs) {
1857
+ this.#debugLog("self announce stopped at deadline");
1858
+ return storedNodes;
1859
+ }
1860
+ const wave = candidates.slice(i, i + SELF_ANNOUNCE_BATCH_SIZE);
1861
+ // Fire all step1 requests in parallel.
1862
+ const step1Settled = await Promise.allSettled(wave.map((c) => this.#sendAnnounceAndWait({
1863
+ node: c.node,
1864
+ nodePublicKey: c.nodePk,
1865
+ senderPublicKey: this.#keyPair.publicKey,
1866
+ senderSecretKey: this.#keyPair.secretKey,
1867
+ pingId: zeroPing,
1868
+ searchPublicKey: this.#keyPair.publicKey,
1869
+ dataPublicKey: this.#announceDataKey.publicKey,
1870
+ sendBack: c.sendBack,
1871
+ allowDirectFallback: true,
1872
+ attempts: SELF_ANNOUNCE_ATTEMPTS
1873
+ })));
1874
+ const step1Hits = [];
1875
+ for (let j = 0; j < wave.length; j++) {
1876
+ const r = step1Settled[j];
1877
+ const c = wave[j];
1878
+ const resp1 = r.status === "fulfilled" ? r.value : undefined;
1879
+ if (!resp1) {
1880
+ this.#debugLog(`self announce step1 no response from ${c.node.host}:${c.node.port}`);
1881
+ continue;
1882
+ }
1883
+ this.#debugLog(`self announce step1 response from ${c.node.host}:${c.node.port} isStored=${resp1.isStored}`);
1884
+ step1Hits.push({ c, resp1 });
1885
+ }
1886
+ const step2Settled = await Promise.allSettled(step1Hits.map(({ c, resp1 }) => this.#sendAnnounceAndWait({
1887
+ node: c.node,
1888
+ nodePublicKey: c.nodePk,
1889
+ senderPublicKey: this.#keyPair.publicKey,
1890
+ senderSecretKey: this.#keyPair.secretKey,
1891
+ pingId: resp1.pingOrDataPublicKey,
1892
+ searchPublicKey: this.#keyPair.publicKey,
1893
+ dataPublicKey: this.#announceDataKey.publicKey,
1894
+ sendBack: c.sendBack,
1895
+ allowDirectFallback: true,
1896
+ attempts: SELF_ANNOUNCE_ATTEMPTS
1897
+ })));
1898
+ for (let j = 0; j < step1Hits.length; j++) {
1899
+ const { c, resp1 } = step1Hits[j];
1900
+ const r2 = step2Settled[j];
1901
+ const resp2 = r2.status === "fulfilled" ? r2.value : undefined;
1902
+ const final = resp2 ?? resp1;
1903
+ if (resp2) {
1904
+ this.#debugLog(`self announce step2 response from ${c.node.host}:${c.node.port} isStored=${resp2.isStored}`);
1905
+ }
1906
+ if (final.isStored === 2) {
1907
+ storedNodes.push(c.node);
1908
+ }
1909
+ const discovered = parsePackedNodes(final.nodes);
1910
+ if (discovered.length > 0) {
1911
+ this.#knownNodes = dedupeNodes([...this.#knownNodes, ...discovered]);
1912
+ }
1913
+ }
1914
+ }
1915
+ return storedNodes;
1916
+ }
1917
+ #ensureSelfAnnounceLoop() {
1918
+ if (this.#selfAnnounceTimer || SELF_ANNOUNCE_INTERVAL_MS <= 0) {
1919
+ return;
1920
+ }
1921
+ this.#selfAnnounceTimer = setInterval(() => {
1922
+ if (this.#selfAnnouncePauseDepth > 0) {
1923
+ return;
1924
+ }
1925
+ if (this.#selfAnnouncePromise) {
1926
+ return;
1927
+ }
1928
+ void this.#runSelfAnnounce(false, Date.now() + JOIN_ANNOUNCE_TIMEOUT_MS).catch((error) => {
1929
+ this.#debugLog(`background self announce failed: ${error.message}`);
1930
+ });
1931
+ }, SELF_ANNOUNCE_INTERVAL_MS);
1932
+ this.#selfAnnounceTimer.unref?.();
1933
+ }
1934
+ #ensureExpressPullLoop() {
1935
+ if (!this.#express?.hasNodes() || this.#expressPollTimer || EXPRESS_PULL_INTERVAL_MS <= 0) {
1936
+ return;
1937
+ }
1938
+ const pull = () => {
1939
+ void this.#express?.pullOnce().catch((error) => {
1940
+ this.#debugLog(`express pull failed: ${error.message}`);
1941
+ });
1942
+ };
1943
+ pull();
1944
+ this.#expressPollTimer = setInterval(pull, EXPRESS_PULL_INTERVAL_MS);
1945
+ this.#expressPollTimer.unref?.();
1946
+ }
1947
+ #ensureFriendConnectionLoop() {
1948
+ if (this.#friendConnectionTimer) {
1949
+ return;
1950
+ }
1951
+ // FRIEND_CONNECTION_LOOP_MS default 250ms (was 2000ms). Toxcore's
1952
+ // reactive 50-1000ms tick is what makes iOS Beagle feel snappy —
1953
+ // every "do this when X arrives" path (DHT-PK reply, cookie request
1954
+ // scheduling, ROUTING_REQUEST after seeing a new relay) snaps to the
1955
+ // tick. With 250ms we're 8× faster than before and within ~5× of
1956
+ // toxcore's ideal. Configurable via DECENT_FRIEND_CONNECTION_LOOP_MS.
1957
+ this.#friendConnectionTimer = setInterval(() => {
1958
+ void this.#doFriendConnections().catch((error) => {
1959
+ this.#debugLog(`friend connection loop failed: ${error.message}`);
1960
+ });
1961
+ }, FRIEND_CONNECTION_LOOP_MS);
1962
+ this.#friendConnectionTimer.unref?.();
1963
+ }
1964
+ async #doFriendConnections() {
1965
+ const now = Date.now();
1966
+ for (const [friendId, friend] of this.#friends.entries()) {
1967
+ const session = this.#friendSessions.get(friendId);
1968
+ // Established session: keepalive + timeout monitoring
1969
+ if (session?.established) {
1970
+ if (session.lastPingRecvMs && now - session.lastPingRecvMs > FRIEND_TIMEOUT_MS) {
1971
+ this.#debugLog(`session timeout for ${friendId} (no ping in ${FRIEND_TIMEOUT_MS}ms)`);
1972
+ this.#friendSessions.delete(friendId);
1973
+ this.#setFriendOffline(friendId);
1974
+ continue;
1975
+ }
1976
+ if (!session.lastPingSentMs || now - session.lastPingSentMs > FRIEND_PING_INTERVAL_MS) {
1977
+ // Toxcore friend_connection.c::send_ping uses PACKET_ID_ALIVE (16),
1978
+ // not PACKET_ID_ONLINE (24). The peer's `ping_lastrecv` is only
1979
+ // updated by ALIVE packets; sending ONLINE repeatedly does not
1980
+ // refresh it, which is why iOS Beagle was timing out our session
1981
+ // after ~32 seconds even though our keepalive timer was firing.
1982
+ await this.#sendMessengerPacket(friendId, PACKET_ID_ALIVE, new Uint8Array()).catch(() => { });
1983
+ }
1984
+ if (session.remote && friend.status !== "online") {
1985
+ this.#setFriendOnline(friendId, session.remote.host, session.remote.port);
1986
+ }
1987
+ continue;
1988
+ }
1989
+ // No active session: tell the friend our DHT public key so they can
1990
+ // find our UDP endpoint and initiate net_crypto from their side. This
1991
+ // is the bidirectional partner of the inbound dhtpk_update flow — the
1992
+ // toxcore Messenger.c::send_dht_public_key_to_friend periodic in C SDK.
1993
+ // Without this, an iOS peer who just accepted our friend request has no
1994
+ // way to learn our endpoint (their own DHT lookup against us only works
1995
+ // if our self-announce stored, which is unreliable against the public
1996
+ // bootstrap nodes). Per-friend cooldown via #dhtPkSendCooldown so we
1997
+ // don't flood onion data requests every 5s loop tick.
1998
+ const lastDhtPkSent = this.#dhtPkSendCooldown.get(friendId) ?? 0;
1999
+ const dhtPkInterval = 25_000;
2000
+ if (now - lastDhtPkSent > dhtPkInterval) {
2001
+ let friendRealPk = session?.friendRealPublicKey;
2002
+ if (!friendRealPk && friend.address) {
2003
+ try {
2004
+ friendRealPk = parseCarrierAddress(friend.address).publicKey;
2005
+ }
2006
+ catch {
2007
+ // Fall through; we'll try the userid form below.
2008
+ }
2009
+ }
2010
+ if (!friendRealPk && friend.pubkey) {
2011
+ try {
2012
+ friendRealPk = base58ToBytes(friend.pubkey);
2013
+ }
2014
+ catch {
2015
+ // Ignore malformed pubkey
2016
+ }
2017
+ }
2018
+ if (friendRealPk && friendRealPk.length === 32) {
2019
+ this.#dhtPkSendCooldown.set(friendId, now);
2020
+ void this.#sendOnionDhtPk(friendRealPk).catch((error) => {
2021
+ this.#debugLog(`dhtpk_send for ${friendId} failed: ${error.message}`);
2022
+ });
2023
+ }
2024
+ }
2025
+ // No active session: try to establish one if we know the friend's endpoint.
2026
+ // If we don't, the C++-initiated path can still bring up the session
2027
+ // when peer reaches us — so this is only one of two ways to recover.
2028
+ const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote;
2029
+ if (!haveEndpoint) {
2030
+ const dhtPk = session?.friendDhtPublicKey;
2031
+ if (dhtPk) {
2032
+ const found = await this.#discoverAndCacheFriendEndpoint(friendId, dhtPk).catch(() => false);
2033
+ if (found) {
2034
+ void this.#initiateSession(friendId).catch((error) => {
2035
+ this.#debugLog(`friend connection loop: initiate after discovery failed for ${friendId}: ${error.message}`);
2036
+ });
2037
+ }
2038
+ }
2039
+ continue;
2040
+ }
2041
+ // Cooldown to avoid flooding cookie requests. Base is 8s; we apply
2042
+ // exponential backoff per consecutive failure so unreachable persisted
2043
+ // friends (e.g. an old simulator that's no longer running) don't keep
2044
+ // hogging loop cycles. Capped so reachable friends still recover within
2045
+ // a minute or two when the network is just slow.
2046
+ const lastAttempt = session?.cookieRequestSentMs ?? session?.handshakeSentMs ?? 0;
2047
+ const failures = this.#cookieRetryCount.get(friendId) ?? 0;
2048
+ const baseCooldownMs = 8000;
2049
+ const maxCooldownMs = 120_000;
2050
+ const cooldownMs = Math.min(maxCooldownMs, baseCooldownMs * Math.pow(1.5, failures));
2051
+ if (now - lastAttempt < cooldownMs) {
2052
+ continue;
2053
+ }
2054
+ // Cookie has been pending without a response for a while: try a LAN
2055
+ // sweep as a last resort. Covers iOS Simulator (loopback) and same-
2056
+ // LAN peers whose actual address isn't in their DHT-PK extras.
2057
+ //
2058
+ // The sweep produces ~250 cookie-request probes per /24, which is
2059
+ // expensive UDP traffic — we only want to fire it for friends that
2060
+ // could plausibly be reachable on the LAN. After a few consecutive
2061
+ // failures (cookie retry count, same metric used above for the
2062
+ // request cooldown), we stop sweeping for the friend altogether so a
2063
+ // long-dead persisted simulator entry doesn't burst 1000 probes/min
2064
+ // into the local subnet forever.
2065
+ const sweepBaseMs = 30_000;
2066
+ const sweepMaxMs = 300_000;
2067
+ const sweepCooldownMs = Math.min(sweepMaxMs, sweepBaseMs * Math.pow(1.5, Math.max(0, failures - 1)));
2068
+ if (LAN_SWEEP_AFTER_MS > 0 &&
2069
+ failures < 8 &&
2070
+ session?.pendingEcho !== undefined &&
2071
+ session.cookieRequestSentMs &&
2072
+ now - session.cookieRequestSentMs > LAN_SWEEP_AFTER_MS &&
2073
+ (session.lastLanSweepMs === undefined || now - session.lastLanSweepMs > sweepCooldownMs)) {
2074
+ session.lastLanSweepMs = now;
2075
+ void this.#sweepLanForCookieResponse(friendId).catch((error) => {
2076
+ this.#debugLog(`lan sweep failed for ${friendId}: ${error.message}`);
2077
+ });
2078
+ continue;
2079
+ }
2080
+ // Reset stale session state and re-initiate
2081
+ if (session && !session.established) {
2082
+ session.pendingEcho = undefined;
2083
+ session.handshakeSentMs = undefined;
2084
+ }
2085
+ void this.#initiateSession(friendId).catch((error) => {
2086
+ this.#debugLog(`friend connection loop: initiate failed for ${friendId}: ${error.message}`);
2087
+ });
2088
+ }
2089
+ }
2090
+ async #sendMessengerPacket(friendId, kind, data) {
2091
+ const session = this.#friendSessions.get(friendId);
2092
+ if (!session?.established || !session.sessionSharedKey || !session.ourBaseNonce) {
2093
+ throw new Error(`friend session unavailable for ${friendId}`);
2094
+ }
2095
+ if (!session.remote && !session.hasTcpRoute) {
2096
+ throw new Error(`friend session has no transport for ${friendId}`);
2097
+ }
2098
+ const payload = concatBytes([Uint8Array.of(kind & 0xff), data]);
2099
+ const packetNumber = session.sendPacketNumber ?? 0;
2100
+ const encrypted = createCryptoDataPacket({
2101
+ sessionSharedKey: session.sessionSharedKey,
2102
+ sentNonce: session.ourBaseNonce,
2103
+ bufferStart: session.receiveBufferStart ?? 0,
2104
+ packetNumber,
2105
+ payload
2106
+ });
2107
+ incrementNonce(session.ourBaseNonce);
2108
+ session.sendPacketNumber = (packetNumber + 1) >>> 0;
2109
+ session.lastPingSentMs = Date.now();
2110
+ await this.#sendToFriend(friendId, encrypted, session);
2111
+ }
2112
+ /**
2113
+ * Send `packet` to `friendId` over whichever transport(s) are
2114
+ * available. Tries UDP first when `session.remote` is set, then TCP
2115
+ * relay if `session.hasTcpRoute` is true. Sending over both is fine
2116
+ * — net_crypto's packet number / buffer_start logic dedups on the
2117
+ * receiving end (toxcore does the same when both transports are up).
2118
+ * Throws only if zero transports succeeded.
2119
+ */
2120
+ async #sendToFriend(friendId, packet, session) {
2121
+ const s = session ?? this.#friendSessions.get(friendId);
2122
+ let udpOk = false;
2123
+ let tcpOk = false;
2124
+ let firstError;
2125
+ if (s?.remote) {
2126
+ try {
2127
+ await this.#sendPacket(packet, s.remote);
2128
+ udpOk = true;
2129
+ }
2130
+ catch (error) {
2131
+ firstError = error;
2132
+ }
2133
+ }
2134
+ if (this.#tcpRelays) {
2135
+ // The TCP relay routes by the friend's DHT pubkey (= the pubkey
2136
+ // they handshook the relay with), not by the friend's real
2137
+ // identity pubkey. iOS Beagle uses different keys for the two,
2138
+ // and our cid table is populated against the DHT pubkey from
2139
+ // ROUTING_REQUEST. Prefer DHT pk when available; fall back to
2140
+ // real pk for peers where they match (older toxcore clients).
2141
+ const tcpKey = s?.friendDhtPublicKey ?? s?.friendRealPublicKey;
2142
+ if (tcpKey) {
2143
+ // Routed DATA only — toxcore's tcp_oob_callback rejects
2144
+ // CRYPTO_DATA (only handles 0x18 / 0x1a), so OOB fallback
2145
+ // would silently drop on iPad's side and leave the session
2146
+ // looking healthy on our side while iPad never sees the
2147
+ // packets.
2148
+ const sent = this.#tcpRelays.sendToFriend(tcpKey, packet);
2149
+ if (sent > 0) {
2150
+ tcpOk = true;
2151
+ }
2152
+ }
2153
+ }
2154
+ if (!udpOk && !tcpOk) {
2155
+ throw firstError ?? new Error(`no transport accepted send for ${friendId}`);
2156
+ }
2157
+ }
2158
+ /**
2159
+ * After a friend sends us PACKET_ID_ONLINE, push our nickname + status
2160
+ * message so their UI replaces "unknown" with our actual display name,
2161
+ * and optionally fire off a configured greeting message. Idempotent —
2162
+ * tracked per friend so we don't spam every keepalive cycle.
2163
+ *
2164
+ * Sends are sequenced with small delays so the receiving peer's UI layer
2165
+ * doesn't process three messenger packets in the same render frame —
2166
+ * iOS Beagle 1.8.6 has a SwiftUI use-after-free in
2167
+ * `_UIHostingView.beginTransaction()` when nickname / status / message
2168
+ * arrive in rapid succession at session establishment, observed as
2169
+ * EXC_BAD_ACCESS in the iOS app's main thread.
2170
+ */
2171
+ #sendProfileAndGreeting(friendId) {
2172
+ if (!this.#profileSentTo.has(friendId)) {
2173
+ this.#profileSentTo.add(friendId);
2174
+ void (async () => {
2175
+ try {
2176
+ // Toxcore PACKET_ID_NICKNAME (48) carries a raw UTF-8 nickname.
2177
+ // Carrier C SDK happens to also call tox_self_set_name, so we
2178
+ // mirror that behaviour for clients that rely on the toxcore
2179
+ // callback. Most Carrier UIs read the displayed name from the
2180
+ // userinfo flatbuffers payload below, but this keeps both paths
2181
+ // consistent.
2182
+ const nameBytes = new TextEncoder().encode(PEER_NICKNAME);
2183
+ await this.#sendMessengerPacket(friendId, PACKET_ID_NICKNAME, nameBytes);
2184
+ this.#debugLog(`nickname "${PEER_NICKNAME}" sent to ${friendId}`);
2185
+ // Carrier C SDK transports its full user-profile (name, descr,
2186
+ // gender, phone, email, region, has_avatar) as a FlatBuffers
2187
+ // PACKET_TYPE_USERINFO (3) payload sent via toxcore's
2188
+ // PACKET_ID_STATUSMESSAGE (49). iOS Beagle's
2189
+ // notify_friend_status_message_cb -> unpack_user_descr ->
2190
+ // packet_decode pipeline expects this exact wrapping. Sending
2191
+ // raw UTF-8 here causes flatbuffers to read random offsets and
2192
+ // crash the app with EXC_BAD_ACCESS in
2193
+ // __flatbuffers_soffset_read_from_pe (observed in Beagle 1.8.6).
2194
+ await sleep(250);
2195
+ const userInfo = encodeUserInfoPacket({
2196
+ name: PEER_NICKNAME,
2197
+ descr: PEER_STATUS_MESSAGE
2198
+ });
2199
+ await this.#sendMessengerPacket(friendId, PACKET_ID_STATUSMESSAGE, userInfo);
2200
+ this.#debugLog(`userinfo sent to ${friendId} (name="${PEER_NICKNAME}", descr="${PEER_STATUS_MESSAGE}")`);
2201
+ }
2202
+ catch (error) {
2203
+ this.#debugLog(`send profile failed for ${friendId}: ${error.message}`);
2204
+ }
2205
+ })();
2206
+ }
2207
+ if (GREETING_TEXT && !this.#greetingSentTo.has(friendId)) {
2208
+ this.#greetingSentTo.add(friendId);
2209
+ void (async () => {
2210
+ try {
2211
+ // Wait so the greeting arrives after the profile pair, giving
2212
+ // the friend's UI time to render the nickname update first.
2213
+ await sleep(700);
2214
+ const carrierMsg = encodeFriendMessagePacket(GREETING_TEXT);
2215
+ await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, carrierMsg);
2216
+ this.#debugLog(`greeting "${GREETING_TEXT}" sent to ${friendId}`);
2217
+ }
2218
+ catch (error) {
2219
+ this.#debugLog(`send greeting failed for ${friendId}: ${error.message}`);
2220
+ }
2221
+ })();
2222
+ }
2223
+ }
2224
+ #cacheFriendRemote(friendId, host, port, realPublicKey, dhtPublicKey) {
2225
+ const friend = this.#friends.get(friendId);
2226
+ if (!friend) {
2227
+ return;
2228
+ }
2229
+ // Important: do NOT persist this candidate to disk. Speculative endpoint
2230
+ // hints (from DHT-PK extras, sendnodes-derived knownNodes, etc.) are
2231
+ // frequently stale relay addresses that aren't actually the friend's
2232
+ // current UDP endpoint. Persisting them then loading on restart causes
2233
+ // the friend connection loop to spam cookie requests to dead addresses
2234
+ // and never recover. Instead we update the in-memory record so the
2235
+ // *current* session's connection loop has somewhere to try, and only
2236
+ // persist remoteHost from #setFriendOnline (which fires after a real
2237
+ // handshake completes — that endpoint is verified working).
2238
+ if (friend.remoteHost !== host || friend.remotePort !== port) {
2239
+ this.#friends.set(friendId, {
2240
+ ...friend,
2241
+ remoteHost: host,
2242
+ remotePort: port
2243
+ });
2244
+ // Intentionally NO #persistFriends() here.
2245
+ }
2246
+ let session = this.#friendSessions.get(friendId);
2247
+ if (!session) {
2248
+ session = {
2249
+ ourSessionKeyPair: createEphemeralKeyPair(),
2250
+ ourBaseNonce: randomBytes(24)
2251
+ };
2252
+ this.#friendSessions.set(friendId, session);
2253
+ }
2254
+ session.remote = { host, port };
2255
+ this.#rememberEndpointCandidate(session, host, port);
2256
+ if (realPublicKey && !session.friendRealPublicKey) {
2257
+ session.friendRealPublicKey = realPublicKey;
2258
+ }
2259
+ if (dhtPublicKey) {
2260
+ session.friendDhtPublicKey = dhtPublicKey;
2261
+ }
2262
+ }
2263
+ #rememberEndpointCandidate(session, host, port) {
2264
+ const now = Date.now();
2265
+ const next = (session.endpointCandidates ?? []).filter((candidate) => !(candidate.host === host && candidate.port === port));
2266
+ next.unshift({ host, port, updatedMs: now });
2267
+ session.endpointCandidates = next
2268
+ .sort((a, b) => b.updatedMs - a.updatedMs)
2269
+ .slice(0, 12);
2270
+ }
2271
+ #collectSessionEndpointCandidates(friendId, friend, session) {
2272
+ // Three buckets: same-LAN private (highest priority for direct UDP),
2273
+ // public/routable, and other private (different LAN, last-resort).
2274
+ // When the local machine has any non-loopback private interface, we
2275
+ // are likely behind NAT and any candidate that matches our subnet is
2276
+ // the only reliable direct-UDP path to the peer; routing to a public
2277
+ // address requires the peer's NAT to allow our incoming packet, which
2278
+ // only succeeds with hole-punching that we don't implement.
2279
+ const sameLan = [];
2280
+ const publicCandidates = [];
2281
+ const otherPrivate = [];
2282
+ const localSubnets = getLocalIpv4Subnets();
2283
+ // Exclude our own IPv4 + UDP port: some toxcore peers include observed
2284
+ // sender addresses in their DHT-PK extras, and we'd otherwise send a
2285
+ // cookie request to ourselves.
2286
+ const ourLocalIps = getLocalIpv4Addresses();
2287
+ const ourLocalPort = this.#udp.localPort();
2288
+ const seen = new Set();
2289
+ const totalCount = () => sameLan.length + publicCandidates.length + otherPrivate.length;
2290
+ const push = (host, port) => {
2291
+ if (!host || !port) {
2292
+ return;
2293
+ }
2294
+ if (ourLocalPort === port && ourLocalIps.includes(host)) {
2295
+ return;
2296
+ }
2297
+ const key = `${host}:${port}`;
2298
+ if (seen.has(key)) {
2299
+ return;
2300
+ }
2301
+ seen.add(key);
2302
+ if (isPrivateAddress(host)) {
2303
+ if (localSubnets.some((subnet) => isInIpv4Subnet(host, subnet))) {
2304
+ sameLan.push({ host, port });
2305
+ }
2306
+ else {
2307
+ otherPrivate.push({ host, port });
2308
+ }
2309
+ }
2310
+ else {
2311
+ publicCandidates.push({ host, port });
2312
+ }
2313
+ };
2314
+ push(session?.remote?.host, session?.remote?.port);
2315
+ push(friend.remoteHost, friend.remotePort);
2316
+ // Operator-supplied extra hosts (DECENT_LAN_SWEEP_EXTRA_HOSTS) are
2317
+ // pushed straight into the same-LAN bucket so e.g. the iOS Simulator
2318
+ // on 127.0.0.1 gets a cookie request on the very first initiateSession
2319
+ // instead of waiting for the 6-second LAN sweep timer. Loopback is not
2320
+ // in the local IPv4 subnet list (getLocalIpv4Subnets excludes internal
2321
+ // interfaces) so it would otherwise land in the "other private"
2322
+ // last-resort bucket and connect noticeably slower.
2323
+ for (const host of LAN_SWEEP_EXTRA_HOSTS) {
2324
+ for (const port of LAN_SWEEP_PORTS) {
2325
+ const key = `${host}:${port}`;
2326
+ if (seen.has(key))
2327
+ continue;
2328
+ seen.add(key);
2329
+ sameLan.push({ host, port });
2330
+ }
2331
+ }
2332
+ for (const candidate of session?.endpointCandidates ?? []) {
2333
+ push(candidate.host, candidate.port);
2334
+ if (totalCount() >= 8) {
2335
+ return [...sameLan, ...publicCandidates, ...otherPrivate].slice(0, 8);
2336
+ }
2337
+ }
2338
+ const dhtId = session?.friendDhtPublicKey ? carrierIdFromPublicKey(session.friendDhtPublicKey) : undefined;
2339
+ for (const node of this.#knownNodes) {
2340
+ if (node.pk !== friendId && (!dhtId || node.pk !== dhtId)) {
2341
+ continue;
2342
+ }
2343
+ push(node.host, node.port);
2344
+ if (totalCount() >= 8) {
2345
+ break;
2346
+ }
2347
+ }
2348
+ // Same-LAN candidates first (most reliable when both peers are behind
2349
+ // the same NAT), then public addresses, then other private addresses.
2350
+ return [...sameLan, ...publicCandidates, ...otherPrivate].slice(0, 8);
2351
+ }
2352
+ async #initiateSession(friendId) {
2353
+ if (!this.#keyPair || !this.#cookieSymmetricKey) {
2354
+ return false;
2355
+ }
2356
+ const friend = this.#friends.get(friendId);
2357
+ if (!friend) {
2358
+ return false;
2359
+ }
2360
+ let session = this.#friendSessions.get(friendId);
2361
+ if (session?.established) {
2362
+ return true;
2363
+ }
2364
+ if (session && session.pendingEcho !== undefined) {
2365
+ // Already waiting on a cookie response — do not flood
2366
+ return false;
2367
+ }
2368
+ if (session && session.handshakeSentMs && Date.now() - session.handshakeSentMs < 6000) {
2369
+ // Recently sent handshake; wait for peer reply
2370
+ return false;
2371
+ }
2372
+ // Resolve friend's real public key
2373
+ let friendRealPk = session?.friendRealPublicKey;
2374
+ if (!friendRealPk && friend.address) {
2375
+ try {
2376
+ friendRealPk = parseCarrierAddress(friend.address).publicKey;
2377
+ }
2378
+ catch (error) {
2379
+ this.#debugLog(`initiate session: parse address failed for ${friendId}: ${error.message}`);
2380
+ return false;
2381
+ }
2382
+ }
2383
+ if (!friendRealPk && friend.pubkey) {
2384
+ try {
2385
+ friendRealPk = base58ToBytes(friend.pubkey);
2386
+ }
2387
+ catch {
2388
+ // Ignore decode errors and bail below
2389
+ }
2390
+ }
2391
+ if (!friendRealPk || friendRealPk.length !== 32) {
2392
+ this.#debugLog(`initiate session: cannot resolve pubkey for ${friendId}`);
2393
+ return false;
2394
+ }
2395
+ // Tie-break who initiates. When both peers send COOKIE_REQUEST in
2396
+ // parallel, each creates an independent local session shell with
2397
+ // ephemeral keys, then computes a shared key from the OTHER side's
2398
+ // handshake. Even though ECDH is commutative, the keys diverge as
2399
+ // soon as one side's shell rotates (which happens on session
2400
+ // timeout, friend-online flap, etc.). Result: chronic decrypt
2401
+ // failures, observed as "crypto data received but no session
2402
+ // matched" cycling every 32s.
2403
+ //
2404
+ // Mirror toxcore: lower-pubkey side stays pure responder. Only the
2405
+ // higher-pubkey side initiates a cookie chain. Peer comparison is
2406
+ // lexicographic on the 32-byte real public key.
2407
+ let pkCmp = 0;
2408
+ for (let i = 0; i < 32; i++) {
2409
+ const a = this.#keyPair.publicKey[i];
2410
+ const b = friendRealPk[i];
2411
+ if (a !== b) {
2412
+ pkCmp = a - b;
2413
+ break;
2414
+ }
2415
+ }
2416
+ if (pkCmp < 0) {
2417
+ // We're the responder side — wait for peer's COOKIE_REQUEST.
2418
+ // Log once per friend to avoid noise from repeated probe calls.
2419
+ if (!this.#initiateSkipLogged.has(friendId)) {
2420
+ this.#initiateSkipLogged.add(friendId);
2421
+ this.#debugLog(`initiate session: deferring to higher-pubkey peer ${friendId}`);
2422
+ }
2423
+ return false;
2424
+ }
2425
+ // Prefer DHT key learned from DHTPK updates; fallback to real key.
2426
+ const friendDhtPk = session?.friendDhtPublicKey ?? friendRealPk;
2427
+ const connectCandidates = this.#collectSessionEndpointCandidates(friendId, friend, session);
2428
+ const tcpAvailable = !!this.#tcpRelays?.isFriendOnline(friendRealPk);
2429
+ if (connectCandidates.length === 0 && !tcpAvailable) {
2430
+ this.#debugLog(`initiate session: no known endpoint for ${friendId} yet`);
2431
+ return false;
2432
+ }
2433
+ if (!session) {
2434
+ session = {
2435
+ ourSessionKeyPair: createEphemeralKeyPair(),
2436
+ ourBaseNonce: randomBytes(24)
2437
+ };
2438
+ this.#friendSessions.set(friendId, session);
2439
+ }
2440
+ session.friendRealPublicKey = friendRealPk;
2441
+ session.friendDhtPublicKey = friendDhtPk;
2442
+ if (tcpAvailable)
2443
+ session.hasTcpRoute = true;
2444
+ if (connectCandidates.length > 0) {
2445
+ session.remote = connectCandidates[0];
2446
+ const endpointKey = `${connectCandidates[0].host}:${connectCandidates[0].port}`;
2447
+ if (this.#lastEndpointSelectedKey.get(friendId) !== endpointKey) {
2448
+ this.#debugLog(`endpoint_selected friend=${friendId} endpoint=${endpointKey}`);
2449
+ this.#lastEndpointSelectedKey.set(friendId, endpointKey);
2450
+ }
2451
+ else {
2452
+ this.#debugVerboseLog(`endpoint_selected friend=${friendId} endpoint=${endpointKey} (unchanged)`);
2453
+ }
2454
+ this.#rememberEndpointCandidate(session, connectCandidates[0].host, connectCandidates[0].port);
2455
+ }
2456
+ else if (tcpAvailable) {
2457
+ this.#debugLog(`endpoint_selected friend=${friendId} endpoint=tcp-relay (no UDP path)`);
2458
+ }
2459
+ const echo = randomBigUint64();
2460
+ session.pendingEcho = echo;
2461
+ session.pendingCookiePeerDhtPublicKey = friendDhtPk;
2462
+ session.cookieRequestSentMs = Date.now();
2463
+ if (!session.recentEchoes)
2464
+ session.recentEchoes = new Map();
2465
+ session.recentEchoes.set(echo, Date.now());
2466
+ // Prune entries older than 30s.
2467
+ const cutoff = Date.now() - 30_000;
2468
+ for (const [k, t] of session.recentEchoes) {
2469
+ if (t < cutoff)
2470
+ session.recentEchoes.delete(k);
2471
+ }
2472
+ const packet = createCookieRequest({
2473
+ senderRealPublicKey: this.#keyPair.publicKey,
2474
+ senderDhtPublicKey: this.#keyPair.publicKey,
2475
+ senderDhtSecretKey: this.#keyPair.secretKey,
2476
+ receiverDhtPublicKey: friendDhtPk,
2477
+ echo
2478
+ });
2479
+ try {
2480
+ let sent = 0;
2481
+ let tcpSent = 0;
2482
+ for (const candidate of connectCandidates) {
2483
+ try {
2484
+ await this.#sendPacket(packet, candidate);
2485
+ sent += 1;
2486
+ }
2487
+ catch {
2488
+ // Keep best-effort behavior.
2489
+ }
2490
+ }
2491
+ // Also send via TCP relay if available, in parallel. Whichever
2492
+ // arrives at the friend first triggers their cookie response.
2493
+ if (tcpAvailable && this.#tcpRelays) {
2494
+ tcpSent = this.#tcpRelays.sendToFriend(friendRealPk, packet);
2495
+ }
2496
+ if (sent === 0 && tcpSent === 0) {
2497
+ throw new Error("no cookie request packet was sent");
2498
+ }
2499
+ // Each unmatched attempt grows the per-friend backoff. Resets when a
2500
+ // cookie response actually matches (see NET_PACKET_COOKIE_RESPONSE
2501
+ // handler) or when the friend is removed.
2502
+ this.#cookieRetryCount.set(friendId, (this.#cookieRetryCount.get(friendId) ?? 0) + 1);
2503
+ const primaryDesc = connectCandidates.length > 0
2504
+ ? `${connectCandidates[0].host}:${connectCandidates[0].port}`
2505
+ : `tcp-relay`;
2506
+ const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}`;
2507
+ if (this.#lastCookieSentKey.get(friendId) !== cookieKey) {
2508
+ this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc}`);
2509
+ this.#lastCookieSentKey.set(friendId, cookieKey);
2510
+ }
2511
+ else {
2512
+ this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
2513
+ }
2514
+ return true;
2515
+ }
2516
+ catch (error) {
2517
+ this.#debugLog(`cookie request send failed for ${friendId}: ${error.message}`);
2518
+ session.pendingEcho = undefined;
2519
+ session.pendingCookiePeerDhtPublicKey = undefined;
2520
+ session.cookieRequestSentMs = undefined;
2521
+ return false;
2522
+ }
2523
+ }
2524
+ /**
2525
+ * Last-resort same-LAN discovery: when we've sent cookie requests to all
2526
+ * known candidates for a friend and haven't heard back, broadcast cookie
2527
+ * requests to every host in our local /24 subnets at toxcore's standard
2528
+ * UDP ports. Whichever host is the friend will receive, decrypt with its
2529
+ * DHT secret key, and respond — the existing cookie response handler
2530
+ * matches by echo, so the right reply wins automatically.
2531
+ *
2532
+ * Useful in the iOS Beagle case where the peer's DHT-PK extras don't
2533
+ * include the peer's own LAN IP and platform sandboxing prevents normal
2534
+ * LAN discovery broadcasts from arriving.
2535
+ */
2536
+ async #sweepLanForCookieResponse(friendId) {
2537
+ if (!this.#keyPair)
2538
+ return;
2539
+ const session = this.#friendSessions.get(friendId);
2540
+ if (!session?.pendingEcho || !session.friendDhtPublicKey)
2541
+ return;
2542
+ if (LAN_SWEEP_AFTER_MS <= 0)
2543
+ return;
2544
+ // Build the same cookie request packet but reuse the pending echo so
2545
+ // the existing cookie response handler matches it.
2546
+ const packet = createCookieRequest({
2547
+ senderRealPublicKey: this.#keyPair.publicKey,
2548
+ senderDhtPublicKey: this.#keyPair.publicKey,
2549
+ senderDhtSecretKey: this.#keyPair.secretKey,
2550
+ receiverDhtPublicKey: session.friendDhtPublicKey,
2551
+ echo: session.pendingEcho
2552
+ });
2553
+ const subnets = getLocalIpv4Subnets();
2554
+ const ourLocalIps = getLocalIpv4Addresses();
2555
+ const ourLocalPort = this.#udp.localPort();
2556
+ let probes = 0;
2557
+ // Optional explicit hosts (DECENT_LAN_SWEEP_EXTRA_HOSTS). Empty by
2558
+ // default so this is invisible for real-device tests, opt-in for
2559
+ // loopback or other known-fixed targets via env var.
2560
+ for (const host of LAN_SWEEP_EXTRA_HOSTS) {
2561
+ if (ourLocalIps.includes(host))
2562
+ continue;
2563
+ for (const port of LAN_SWEEP_PORTS) {
2564
+ if (port === ourLocalPort && ourLocalIps.includes(host))
2565
+ continue;
2566
+ try {
2567
+ await this.#sendPacket(packet, { host, port });
2568
+ probes += 1;
2569
+ }
2570
+ catch {
2571
+ // best-effort
2572
+ }
2573
+ }
2574
+ }
2575
+ for (const subnet of subnets) {
2576
+ const network = subnet.networkBits;
2577
+ const mask = subnet.maskBits;
2578
+ const broadcast = (network | (~mask >>> 0)) >>> 0;
2579
+ // Only sweep /23 or smaller (≤512 hosts) to keep the burst bounded.
2580
+ if (((~mask >>> 0) & 0xffffffff) > 0x1ff)
2581
+ continue;
2582
+ for (let addr = (network + 1) >>> 0; addr < broadcast; addr = (addr + 1) >>> 0) {
2583
+ const host = `${(addr >>> 24) & 0xff}.${(addr >>> 16) & 0xff}.${(addr >>> 8) & 0xff}.${addr & 0xff}`;
2584
+ if (ourLocalIps.includes(host))
2585
+ continue;
2586
+ for (const port of LAN_SWEEP_PORTS) {
2587
+ if (ourLocalPort === port && ourLocalIps.includes(host))
2588
+ continue;
2589
+ try {
2590
+ await this.#sendPacket(packet, { host, port });
2591
+ probes += 1;
2592
+ }
2593
+ catch {
2594
+ // best-effort
2595
+ }
2596
+ }
2597
+ }
2598
+ }
2599
+ if (probes > 0) {
2600
+ this.#debugLog(`lan_sweep friend=${friendId} probes=${probes}`);
2601
+ }
2602
+ }
2603
+ /**
2604
+ * Send an onion DHT-PK announcement to a friend so they learn our DHT
2605
+ * public key + endpoint hints. This is the bidirectional partner of the
2606
+ * inbound dhtpk_update flow: without this, a peer who accepted our friend
2607
+ * request has no way to find our UDP endpoint and net_crypto cannot start
2608
+ * from their side.
2609
+ *
2610
+ * Packet layout matches toxcore Messenger.c:send_dht_public_key_to_friend
2611
+ * — onion data with innerPacketId = CRYPTO_PACKET_DHTPK (156), payload =
2612
+ * noReplay (8 BE) || dhtPublicKey (32) || extra (packed nodes).
2613
+ */
2614
+ async #sendOnionDhtPk(friendRealPublicKey) {
2615
+ if (!this.#keyPair)
2616
+ return false;
2617
+ const friendId = carrierIdFromPublicKey(friendRealPublicKey);
2618
+ const routes = await this.#discoverFriendRoutes(friendRealPublicKey).catch(() => []);
2619
+ if (routes.length === 0) {
2620
+ const failures = (this.#dhtPkConsecutiveFailures.get(friendId) ?? 0) + 1;
2621
+ this.#dhtPkConsecutiveFailures.set(friendId, failures);
2622
+ // Only log first failure at debug level; subsequent retries go to verbose
2623
+ // since a stale persisted friend can hit this repeatedly (with backoff).
2624
+ if (failures === 1) {
2625
+ this.#debugLog(`dhtpk_send no routes available for ${friendId} (consecutive_failures=1)`);
2626
+ }
2627
+ else {
2628
+ this.#debugVerboseLog(`dhtpk_send no routes available for ${friendId} (consecutive_failures=${failures})`);
2629
+ }
2630
+ return false;
2631
+ }
2632
+ // Reset failure counter on first success at finding any route.
2633
+ this.#dhtPkConsecutiveFailures.delete(friendId);
2634
+ // Build inner DHTPK payload.
2635
+ const noReplay = BigInt(Math.floor(Date.now() / 1000));
2636
+ const noReplayBytes = new Uint8Array(8);
2637
+ {
2638
+ let v = noReplay;
2639
+ for (let i = 7; i >= 0; i--) {
2640
+ noReplayBytes[i] = Number(v & 0xffn);
2641
+ v >>= 8n;
2642
+ }
2643
+ }
2644
+ // Pack our currently-connected TCP relays as DHT-PK extras. iOS
2645
+ // Beagle's friend_connection layer reads these to populate its
2646
+ // tcp_relays list for us, then issues OOB_SEND with cookie request
2647
+ // on each — without this hint, iPad has no idea which relays might
2648
+ // reach us, and iPad's friend_connection stalls at CONNECTING when
2649
+ // its own DHT search for our pubkey fails (the common case on
2650
+ // residential ISPs where self-announce doesn't propagate).
2651
+ // Mirrors toxcore's send_dht_pk_packet which calls
2652
+ // tcp_copy_connected_relays to fill extras.
2653
+ let extras = new Uint8Array(0);
2654
+ if (this.#tcpRelays) {
2655
+ const relays = this.#tcpRelays.connectedRelays(3); // MAX_SHARED_RELAYS
2656
+ const packed = [];
2657
+ for (const r of relays) {
2658
+ // Only IPv4 TCP entries supported here; matches what we parse.
2659
+ // Format per packed-nodes spec: family(1) + ipv4(4) + port(2 BE) + pk(32)
2660
+ const parts = r.host.split(".").map((p) => Number.parseInt(p, 10));
2661
+ if (parts.length !== 4 || parts.some((n) => !(n >= 0 && n <= 255)))
2662
+ continue;
2663
+ const entry = new Uint8Array(1 + 4 + 2 + 32);
2664
+ entry[0] = 130; // 0x82 = TCP_FAMILY_IPV4
2665
+ entry[1] = parts[0];
2666
+ entry[2] = parts[1];
2667
+ entry[3] = parts[2];
2668
+ entry[4] = parts[3];
2669
+ entry[5] = (r.port >> 8) & 0xff;
2670
+ entry[6] = r.port & 0xff;
2671
+ entry.set(r.serverPublicKey, 7);
2672
+ packed.push(entry);
2673
+ }
2674
+ if (packed.length > 0) {
2675
+ extras = concatBytes(packed);
2676
+ }
2677
+ }
2678
+ const innerPayload = concatBytes([
2679
+ noReplayBytes,
2680
+ this.#keyPair.publicKey,
2681
+ extras
2682
+ ]);
2683
+ let sent = 0;
2684
+ for (const route of routes) {
2685
+ try {
2686
+ const nonce = randomBytes(24);
2687
+ const onionData = createOnionDataPacket({
2688
+ senderPublicKey: this.#keyPair.publicKey,
2689
+ senderSecretKey: this.#keyPair.secretKey,
2690
+ receiverPublicKey: friendRealPublicKey,
2691
+ nonce,
2692
+ innerPacketId: CRYPTO_PACKET_DHTPK,
2693
+ innerPayload
2694
+ });
2695
+ const dataRequest = createOnionDataRequest({
2696
+ destinationPublicKey: friendRealPublicKey,
2697
+ routePublicKey: route.routePublicKey,
2698
+ nonce,
2699
+ onionDataPacket: onionData
2700
+ });
2701
+ await this.#sendThroughOnionPath(dataRequest, route.node, sent);
2702
+ sent += 1;
2703
+ if (sent >= 4)
2704
+ break; // a few routes is enough
2705
+ }
2706
+ catch (error) {
2707
+ this.#debugLog(`dhtpk_send route failed via ${route.node.host}:${route.node.port}: ${error.message}`);
2708
+ }
2709
+ }
2710
+ if (sent > 0) {
2711
+ this.#debugLog(`dhtpk_sent friend=${friendId} routes=${sent} noReplay=${noReplay.toString()}`);
2712
+ }
2713
+ return sent > 0;
2714
+ }
2715
+ #setFriendOnline(friendId, remoteHost, remotePort) {
2716
+ const friend = this.#friends.get(friendId);
2717
+ if (!friend) {
2718
+ return;
2719
+ }
2720
+ // Don't persist a synthetic TCP-relay-routed remote ("tcp:<dhtpk>:0").
2721
+ // The friend connection loop reading it next run would try to UDP-send
2722
+ // to that host and silently drop. Keep status=online + acceptedAt
2723
+ // updated, but only persist remoteHost when it's a real UDP endpoint.
2724
+ const isSynthetic = remoteHost.startsWith("tcp:") || remotePort === 0;
2725
+ const persistedHost = isSynthetic ? friend.remoteHost : remoteHost;
2726
+ const persistedPort = isSynthetic ? friend.remotePort : remotePort;
2727
+ const changed = friend.status !== "online" || friend.remoteHost !== persistedHost || friend.remotePort !== persistedPort;
2728
+ this.#friends.set(friendId, {
2729
+ ...friend,
2730
+ status: "online",
2731
+ remoteHost: persistedHost,
2732
+ remotePort: persistedPort
2733
+ });
2734
+ if (changed) {
2735
+ this.#persistFriends();
2736
+ this.#events.emit("friendConnection", {
2737
+ pubkey: friendId,
2738
+ status: "connected"
2739
+ });
2740
+ }
2741
+ }
2742
+ #setFriendOffline(friendId) {
2743
+ // Re-arm the profile (nickname + userinfo) flag so the next reconnect
2744
+ // pushes it again — toxcore peers persist nicknames across reconnects
2745
+ // but it's the responsible thing to refresh once per session in case
2746
+ // the peer's UI dropped state.
2747
+ //
2748
+ // Greeting flag is intentionally NOT reset: the configured greeting is
2749
+ // a one-time "Hi from JS" intended to fire once per process startup,
2750
+ // not on every reconnect. Keeping #greetingSentTo set across offline
2751
+ // transitions prevents the user from seeing the same greeting message
2752
+ // repeated each time the session bounces.
2753
+ this.#profileSentTo.delete(friendId);
2754
+ const friend = this.#friends.get(friendId);
2755
+ if (!friend) {
2756
+ return;
2757
+ }
2758
+ if (friend.status === "offline") {
2759
+ return;
2760
+ }
2761
+ this.#friends.set(friendId, {
2762
+ ...friend,
2763
+ status: "offline"
2764
+ });
2765
+ this.#persistFriends();
2766
+ this.#events.emit("friendConnection", {
2767
+ pubkey: friendId,
2768
+ status: "disconnected"
2769
+ });
2770
+ }
2771
+ async #sendAnnounceAndWait(opts) {
2772
+ this.#debugLog(`announce request target=${opts.node.host}:${opts.node.port} ` +
2773
+ `searchEqSender=${bytesEqual(opts.searchPublicKey, opts.senderPublicKey)} ` +
2774
+ `dataKeyZero=${isAllZero(opts.dataPublicKey)}`);
2775
+ const request = createOnionAnnounceRequest({
2776
+ senderPublicKey: opts.senderPublicKey,
2777
+ senderSecretKey: opts.senderSecretKey,
2778
+ nodePublicKey: opts.nodePublicKey,
2779
+ pingId: opts.pingId,
2780
+ searchPublicKey: opts.searchPublicKey,
2781
+ dataPublicKey: opts.dataPublicKey,
2782
+ sendBack: opts.sendBack
2783
+ });
2784
+ const attempts = opts.attempts ?? 3;
2785
+ for (let attempt = 0; attempt < attempts; attempt++) {
2786
+ const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
2787
+ requesterSecretKey: opts.senderSecretKey,
2788
+ nodePublicKey: opts.nodePublicKey
2789
+ });
2790
+ await this.#sendThroughOnionPath(request, opts.node, attempt);
2791
+ const response = await waiter;
2792
+ if (response) {
2793
+ this.#recordNodeSuccess(`${opts.node.host}:${opts.node.port}`);
2794
+ return response;
2795
+ }
2796
+ this.#recordNodeFailure(`${opts.node.host}:${opts.node.port}`);
2797
+ }
2798
+ if (opts.allowDirectFallback) {
2799
+ const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
2800
+ requesterSecretKey: opts.senderSecretKey,
2801
+ nodePublicKey: opts.nodePublicKey
2802
+ });
2803
+ await this.#sendPacket(request, opts.node);
2804
+ const response = await waiter;
2805
+ if (response) {
2806
+ this.#recordNodeSuccess(`${opts.node.host}:${opts.node.port}`);
2807
+ return response;
2808
+ }
2809
+ this.#recordNodeFailure(`${opts.node.host}:${opts.node.port}`);
2810
+ }
2811
+ return undefined;
2812
+ }
2813
+ async #waitForAnnounceResponse(node, sendBack, openOpts) {
2814
+ return new Promise((resolve) => {
2815
+ const timeout = setTimeout(() => {
2816
+ cleanup();
2817
+ resolve(undefined);
2818
+ }, ANNOUNCE_WAIT_TIMEOUT_MS);
2819
+ const onDatagram = ({ data, remote }) => {
2820
+ const source = `${remote.address}:${remote.port}`;
2821
+ const packet = stripCarrierMagic(data);
2822
+ if (packet.length === 0 || packet[0] !== NET_PACKET_ONION_ANNOUNCE_RESPONSE) {
2823
+ return;
2824
+ }
2825
+ // Pre-filter by sendBack BEFORE attempting decrypt. With parallel
2826
+ // batches (8-12 in-flight requests), every inbound response is
2827
+ // delivered to all listeners — only the one with the matching
2828
+ // sendBack should attempt decrypt. Without this guard, the other
2829
+ // 11 listeners would all run nacl.box.open with their own
2830
+ // requesterSecretKey + nodePublicKey, fail (because the response
2831
+ // was for a different listener), and log "decrypt failed from
2832
+ // <source>" — which makes it look like the bootstrap is broken
2833
+ // when it's actually fine. The sendBack token is in plaintext at
2834
+ // a fixed offset (1..1+SEND_BACK_SIZE), so we can match it
2835
+ // without decrypting.
2836
+ const SEND_BACK_OFFSET = 1;
2837
+ const SEND_BACK_LEN = 8;
2838
+ if (packet.length < SEND_BACK_OFFSET + SEND_BACK_LEN) {
2839
+ return;
2840
+ }
2841
+ const incoming = packet.subarray(SEND_BACK_OFFSET, SEND_BACK_OFFSET + SEND_BACK_LEN);
2842
+ if (!bytesEqual(incoming, sendBack)) {
2843
+ return; // not for this listener — silent
2844
+ }
2845
+ // Now this packet IS for us. A decrypt failure here is real.
2846
+ const opened = openOnionAnnounceResponse(packet, openOpts);
2847
+ if (!opened) {
2848
+ this.#debugLog(`announce response decrypt failed from ${source}`);
2849
+ return;
2850
+ }
2851
+ this.#recordNodeSuccess(source);
2852
+ this.#debugLog(`announce response accepted from ${source}`);
2853
+ cleanup();
2854
+ resolve(opened);
2855
+ };
2856
+ const cleanup = () => {
2857
+ clearTimeout(timeout);
2858
+ this.#udp.off("datagram", onDatagram);
2859
+ };
2860
+ this.#udp.on("datagram", onDatagram);
2861
+ });
2862
+ }
2863
+ async #sendPacket(packet, node) {
2864
+ this.#tracePacket("tx", packet, node);
2865
+ const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
2866
+ await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
2867
+ }
2868
+ async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0) {
2869
+ const path = this.#selectOnionPath(nodeD, pathOffset);
2870
+ if (!path) {
2871
+ this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
2872
+ await this.#sendPacket(payloadForNodeD, nodeD);
2873
+ return;
2874
+ }
2875
+ this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
2876
+ const packet = createOnionRequest0({
2877
+ nodeAPublicKey: path.nodeA.publicKey,
2878
+ nodeBHost: path.nodeB.host,
2879
+ nodeBPort: path.nodeB.port,
2880
+ nodeBPublicKey: path.nodeB.publicKey,
2881
+ nodeCHost: path.nodeC.host,
2882
+ nodeCPort: path.nodeC.port,
2883
+ nodeCPublicKey: path.nodeC.publicKey,
2884
+ nodeDHost: nodeD.host,
2885
+ nodeDPort: nodeD.port,
2886
+ payloadForNodeD
2887
+ });
2888
+ await this.#sendPacket(packet, path.nodeA.node);
2889
+ }
2890
+ #selectOnionPath(nodeD, pathOffset = 0) {
2891
+ const nodeDId = `${nodeD.host}:${nodeD.port}`;
2892
+ const candidates = [];
2893
+ const bootstrapIds = new Set(this.#opts.bootstrapNodes.map((node) => `${node.host}:${node.port}`));
2894
+ const seenHosts = new Set();
2895
+ const seenPublicKeys = new Set();
2896
+ for (const node of this.#knownNodes) {
2897
+ if (!node.pk) {
2898
+ continue;
2899
+ }
2900
+ const id = `${node.host}:${node.port}`;
2901
+ if (id === nodeDId || node.host === nodeD.host || seenHosts.has(node.host)) {
2902
+ continue;
2903
+ }
2904
+ // Skip blacklisted relays — onion paths through dead nodes
2905
+ // silently swallow the request and the announce times out.
2906
+ if (this.#isNodeBlacklisted(id)) {
2907
+ continue;
2908
+ }
2909
+ try {
2910
+ const pk = base58ToBytes(node.pk);
2911
+ const pkHex = Buffer.from(pk).toString("hex");
2912
+ if (pk.length === 32 && !seenPublicKeys.has(pkHex)) {
2913
+ seenHosts.add(node.host);
2914
+ seenPublicKeys.add(pkHex);
2915
+ candidates.push({
2916
+ node,
2917
+ publicKey: pk,
2918
+ score: this.#nodeScore(id) + relayPortScore(node.port) + (bootstrapIds.has(id) ? 4 : 0)
2919
+ });
2920
+ }
2921
+ }
2922
+ catch {
2923
+ // Ignore bad keys.
2924
+ }
2925
+ }
2926
+ if (candidates.length < 3) {
2927
+ return undefined;
2928
+ }
2929
+ candidates.sort((a, b) => b.score - a.score);
2930
+ const preferredRelays = candidates.filter((item) => isLikelyStableRelayPort(item.node.port));
2931
+ const healthy = preferredRelays.filter((item) => item.score > -2);
2932
+ const pool = healthy.length >= 3 ? healthy : preferredRelays.length >= 3 ? preferredRelays : candidates;
2933
+ const count = pool.length;
2934
+ const i0 = pathOffset % count;
2935
+ const i1 = (pathOffset + 1) % count;
2936
+ const i2 = (pathOffset + 2) % count;
2937
+ return {
2938
+ nodeA: pool[i0],
2939
+ nodeB: {
2940
+ node: pool[i1].node,
2941
+ host: pool[i1].node.host,
2942
+ port: pool[i1].node.port,
2943
+ publicKey: pool[i1].publicKey
2944
+ },
2945
+ nodeC: {
2946
+ node: pool[i2].node,
2947
+ host: pool[i2].node.host,
2948
+ port: pool[i2].node.port,
2949
+ publicKey: pool[i2].publicKey
2950
+ }
2951
+ };
2952
+ }
2953
+ async #sendDirectCryptoFriendRequest(friendPublicKey, friendReqPayload) {
2954
+ if (!this.#keyPair) {
2955
+ throw new Error("Peer is not started");
2956
+ }
2957
+ const cryptoPacket = createToxDhtCryptoRequest({
2958
+ sender: this.#keyPair,
2959
+ receiverPublicKey: friendPublicKey,
2960
+ requestId: CRYPTO_PACKET_FRIEND_REQ,
2961
+ data: friendReqPayload
2962
+ });
2963
+ const targets = dedupeNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes);
2964
+ let sent = 0;
2965
+ for (const node of targets) {
2966
+ try {
2967
+ await this.#sendPacket(cryptoPacket, node);
2968
+ sent += 1;
2969
+ }
2970
+ catch {
2971
+ // Keep best-effort semantics while trying multiple nodes.
2972
+ }
2973
+ }
2974
+ if (sent === 0) {
2975
+ throw new Error("friend request dispatch failed: no packet was sent");
2976
+ }
2977
+ this.#debugLog(`direct friend request sent via ${sent} targets`);
2978
+ }
2979
+ #debugLog(message) {
2980
+ if (!this.#debug) {
2981
+ return;
2982
+ }
2983
+ const label = this.#opts.debugLabel ? `:${this.#opts.debugLabel}` : "";
2984
+ console.log(`[peer-debug${label}] ${message}`);
2985
+ }
2986
+ /**
2987
+ * Verbose debug log — DHT plumbing chatter (per-onion-hop sends, decrypt
2988
+ * failures from in-flight stale responses, isStored=0 announce results,
2989
+ * etc.). Off by default even with DECENT_DEBUG=1; only emitted when
2990
+ * DECENT_DEBUG_VERBOSE=1 is also set.
2991
+ */
2992
+ #debugVerboseLog(message) {
2993
+ if (!this.#debugVerbose) {
2994
+ return;
2995
+ }
2996
+ const label = this.#opts.debugLabel ? `:${this.#opts.debugLabel}` : "";
2997
+ console.log(`[peer-debug${label}] ${message}`);
2998
+ }
2999
+ #tracePacket(direction, packet, remote) {
3000
+ if (!this.#packetTrace || packet.length === 0) {
3001
+ return;
3002
+ }
3003
+ const type = packet[0];
3004
+ const label = this.#opts.debugLabel ? `:${this.#opts.debugLabel}` : "";
3005
+ console.log(`[peer-packet${label}] ${direction} type=0x${type.toString(16).padStart(2, "0")} ` +
3006
+ `len=${packet.length} peer=${remote.host}:${remote.port}`);
3007
+ }
3008
+ #recordNodeSuccess(nodeId) {
3009
+ const prev = this.#nodeHealth.get(nodeId) ?? { ok: 0, fail: 0, lastOkMs: 0 };
3010
+ this.#nodeHealth.set(nodeId, {
3011
+ ok: prev.ok + 1,
3012
+ fail: prev.fail,
3013
+ lastOkMs: Date.now()
3014
+ });
3015
+ // Any success clears the blacklist — the node is alive again.
3016
+ this.#nodeBlacklist.delete(nodeId);
3017
+ }
3018
+ #recordNodeFailure(nodeId) {
3019
+ const prev = this.#nodeHealth.get(nodeId) ?? { ok: 0, fail: 0, lastOkMs: 0 };
3020
+ const next = {
3021
+ ok: prev.ok,
3022
+ fail: prev.fail + 1,
3023
+ lastOkMs: prev.lastOkMs
3024
+ };
3025
+ this.#nodeHealth.set(nodeId, next);
3026
+ // Soft blacklist: a node that has accumulated NODE_BLACKLIST_THRESHOLD
3027
+ // consecutive failures (no successful response in between resets it
3028
+ // via #recordNodeSuccess) goes onto the blacklist with a 5-minute
3029
+ // TTL. This stops the parallel announce batches from spending half
3030
+ // their slots on a known-dead bootstrap; the surviving healthy nodes
3031
+ // pick up the slack. The blacklist self-heals after the TTL so a
3032
+ // temporarily-flapping node gets re-tried later.
3033
+ const consecutive = next.fail - next.ok;
3034
+ if (consecutive >= NODE_BLACKLIST_THRESHOLD) {
3035
+ const ttl = Math.min(NODE_BLACKLIST_MAX_TTL_MS, NODE_BLACKLIST_BASE_TTL_MS * Math.pow(1.5, consecutive - NODE_BLACKLIST_THRESHOLD));
3036
+ this.#nodeBlacklist.set(nodeId, Date.now() + ttl);
3037
+ this.#debugVerboseLog(`node ${nodeId} blacklisted for ${Math.round(ttl / 1000)}s after ${consecutive} consecutive fails`);
3038
+ }
3039
+ }
3040
+ /**
3041
+ * True if this node is currently shadowbanned for repeated failures.
3042
+ * Self-expires after the TTL set in #recordNodeFailure. Used by every
3043
+ * candidate-selection site to skip dead bootstraps without needing to
3044
+ * remove them from #knownNodes (which would lose neighbor info on
3045
+ * restart).
3046
+ */
3047
+ #isNodeBlacklisted(nodeId) {
3048
+ const expiry = this.#nodeBlacklist.get(nodeId);
3049
+ if (expiry === undefined)
3050
+ return false;
3051
+ if (Date.now() > expiry) {
3052
+ this.#nodeBlacklist.delete(nodeId);
3053
+ return false;
3054
+ }
3055
+ return true;
3056
+ }
3057
+ #nodeScore(nodeId) {
3058
+ const h = this.#nodeHealth.get(nodeId);
3059
+ if (!h) {
3060
+ return 0;
3061
+ }
3062
+ const recentBonus = Date.now() - h.lastOkMs < 60_000 ? 2 : 0;
3063
+ return (h.ok * 2) - h.fail + recentBonus;
3064
+ }
3065
+ #pauseSelfAnnounce() {
3066
+ this.#selfAnnouncePauseDepth += 1;
3067
+ return () => {
3068
+ this.#selfAnnouncePauseDepth = Math.max(0, this.#selfAnnouncePauseDepth - 1);
3069
+ };
3070
+ }
3071
+ async #runSelfAnnounce(force, deadlineMs) {
3072
+ if (this.#selfAnnouncePromise) {
3073
+ await this.#selfAnnouncePromise.catch(() => { });
3074
+ }
3075
+ this.#selfAnnouncePromise = this.#announceSelfBestEffort(force, deadlineMs);
3076
+ try {
3077
+ const result = await this.#selfAnnouncePromise;
3078
+ return result ?? [];
3079
+ }
3080
+ finally {
3081
+ this.#selfAnnouncePromise = undefined;
3082
+ }
3083
+ }
3084
+ async #loadPersistedFriends() {
3085
+ if (!this.#friendStoreFile) {
3086
+ return;
3087
+ }
3088
+ try {
3089
+ const raw = await readFile(this.#friendStoreFile, "utf8");
3090
+ const parsed = JSON.parse(raw);
3091
+ if (!Array.isArray(parsed)) {
3092
+ return;
3093
+ }
3094
+ for (const record of parsed) {
3095
+ if (!record || typeof record.pubkey !== "string" || !record.pubkey) {
3096
+ continue;
3097
+ }
3098
+ // If the persisted record has no acceptedAt, the saved
3099
+ // remoteHost/remotePort may be stale (older builds persisted
3100
+ // speculative DHT-PK extras). Drop them so the connection loop
3101
+ // does fresh discovery via DHT-PK rather than spamming cookie
3102
+ // requests to a dead bootstrap relay address.
3103
+ const trusted = record.acceptedAt ? record : {
3104
+ ...record,
3105
+ remoteHost: undefined,
3106
+ remotePort: undefined,
3107
+ status: record.status === "online" ? "offline" : record.status
3108
+ };
3109
+ this.#friends.set(record.pubkey, trusted);
3110
+ }
3111
+ this.#debugLog(`loaded ${this.#friends.size} persisted friends`);
3112
+ }
3113
+ catch {
3114
+ // Ignore missing/invalid friend store on startup.
3115
+ }
3116
+ }
3117
+ #persistFriends() {
3118
+ if (!this.#friendStoreFile) {
3119
+ return;
3120
+ }
3121
+ const payload = JSON.stringify([...this.#friends.values()], null, 2);
3122
+ void mkdir(dirname(this.#friendStoreFile), { recursive: true })
3123
+ .then(() => writeFile(this.#friendStoreFile, payload, "utf8"))
3124
+ .catch((error) => {
3125
+ this.#debugLog(`persist friends failed: ${error.message}`);
3126
+ });
3127
+ }
3128
+ }
3129
+ function decodeUtf8Best(payload) {
3130
+ if (payload.length === 0)
3131
+ return "";
3132
+ try {
3133
+ return new TextDecoder("utf-8", { fatal: false }).decode(payload);
3134
+ }
3135
+ catch {
3136
+ return "";
3137
+ }
3138
+ }
3139
+ function tryDecodeCarrierMessagePacket(payload) {
3140
+ try {
3141
+ const decoded = decodeCarrierPacket(payload);
3142
+ if (decoded.type !== PACKET_TYPE_MESSAGE) {
3143
+ return undefined;
3144
+ }
3145
+ return new TextDecoder().decode(decoded.data);
3146
+ }
3147
+ catch {
3148
+ return undefined;
3149
+ }
3150
+ }
3151
+ function splitFriendRequestPayload(payload) {
3152
+ if (payload.length >= 4) {
3153
+ const withPrefix = payload.slice(4);
3154
+ try {
3155
+ const decoded = decodeCarrierPacket(withPrefix);
3156
+ if (decoded.type === PACKET_TYPE_FRIEND_REQUEST) {
3157
+ return {
3158
+ nospam: readUint32LE(payload, 0),
3159
+ carrierPacket: withPrefix
3160
+ };
3161
+ }
3162
+ }
3163
+ catch {
3164
+ // Fall through and try payload without a nospam prefix.
3165
+ }
3166
+ }
3167
+ try {
3168
+ const decoded = decodeCarrierPacket(payload);
3169
+ if (decoded.type === PACKET_TYPE_FRIEND_REQUEST) {
3170
+ return {
3171
+ nospam: 0,
3172
+ carrierPacket: payload
3173
+ };
3174
+ }
3175
+ }
3176
+ catch {
3177
+ return undefined;
3178
+ }
3179
+ return undefined;
3180
+ }
3181
+ function dedupeNodes(nodes) {
3182
+ const byAddress = new Map();
3183
+ for (const node of nodes) {
3184
+ byAddress.set(`${node.host}:${node.port}`, node);
3185
+ }
3186
+ return [...byAddress.values()];
3187
+ }
3188
+ function uint32ToLe(value) {
3189
+ if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) {
3190
+ throw new Error("friend nospam must be uint32");
3191
+ }
3192
+ return new Uint8Array([
3193
+ value & 0xff,
3194
+ (value >>> 8) & 0xff,
3195
+ (value >>> 16) & 0xff,
3196
+ (value >>> 24) & 0xff
3197
+ ]);
3198
+ }
3199
+ function readUint32LE(bytes, offset) {
3200
+ return (bytes[offset] |
3201
+ (bytes[offset + 1] << 8) |
3202
+ (bytes[offset + 2] << 16) |
3203
+ (bytes[offset + 3] << 24)) >>> 0;
3204
+ }
3205
+ function readUint64BE(bytes, offset) {
3206
+ let v = 0n;
3207
+ for (let i = 0; i < 8; i++) {
3208
+ v = (v << 8n) | BigInt(bytes[offset + i] ?? 0);
3209
+ }
3210
+ return v;
3211
+ }
3212
+ function stripCarrierMagic(data) {
3213
+ if (data.length >= 5 &&
3214
+ data[0] === 0x69 &&
3215
+ data[1] === 0x76 &&
3216
+ data[2] === 0x65 &&
3217
+ data[3] === 0x67) {
3218
+ return data.slice(4);
3219
+ }
3220
+ return data;
3221
+ }
3222
+ function parsePackedNodes(data) {
3223
+ const nodes = [];
3224
+ let offset = 0;
3225
+ while (offset + 1 <= data.length) {
3226
+ const family = data[offset];
3227
+ offset += 1;
3228
+ let host = "";
3229
+ let isTcp = false;
3230
+ if (family === 2 || family === 130) {
3231
+ if (offset + 4 + 2 + 32 > data.length) {
3232
+ break;
3233
+ }
3234
+ host = [...data.slice(offset, offset + 4)].join(".");
3235
+ offset += 4;
3236
+ isTcp = family === 130; // TCP_FAMILY_IPV4
3237
+ }
3238
+ else if (family === 10 || family === 138) {
3239
+ if (offset + 16 + 2 + 32 > data.length) {
3240
+ break;
3241
+ }
3242
+ const parts = [];
3243
+ for (let i = 0; i < 8; i++) {
3244
+ parts.push(((data[offset + i * 2] << 8) | data[offset + i * 2 + 1]).toString(16));
3245
+ }
3246
+ host = parts.join(":");
3247
+ offset += 16;
3248
+ isTcp = family === 138; // TCP_FAMILY_IPV6
3249
+ }
3250
+ else {
3251
+ break;
3252
+ }
3253
+ const port = (data[offset] << 8) | data[offset + 1];
3254
+ offset += 2;
3255
+ const pk = carrierIdFromPublicKey(data.slice(offset, offset + 32));
3256
+ offset += 32;
3257
+ nodes.push({ host, port, pk, isTcp });
3258
+ }
3259
+ return nodes;
3260
+ }
3261
+ function bytesEqual(a, b) {
3262
+ if (a.length !== b.length) {
3263
+ return false;
3264
+ }
3265
+ let diff = 0;
3266
+ for (let i = 0; i < a.length; i++) {
3267
+ diff |= a[i] ^ b[i];
3268
+ }
3269
+ return diff === 0;
3270
+ }
3271
+ function createEphemeralKeyPair() {
3272
+ return nacl.box.keyPair();
3273
+ }
3274
+ function isAllZero(bytes) {
3275
+ for (let i = 0; i < bytes.length; i++) {
3276
+ if (bytes[i] !== 0) {
3277
+ return false;
3278
+ }
3279
+ }
3280
+ return true;
3281
+ }
3282
+ function isLikelyStableRelayPort(port) {
3283
+ return port >= 33445 && port <= 33449;
3284
+ }
3285
+ function relayPortScore(port) {
3286
+ return isLikelyStableRelayPort(port) ? 3 : 0;
3287
+ }
3288
+ function sleep(ms) {
3289
+ return new Promise((resolve) => {
3290
+ setTimeout(resolve, Math.max(0, ms));
3291
+ });
3292
+ }
3293
+ function randomBigUint64() {
3294
+ const bytes = randomBytes(8);
3295
+ let v = 0n;
3296
+ for (let i = 7; i >= 0; i--) {
3297
+ v = (v << 8n) | BigInt(bytes[i]);
3298
+ }
3299
+ return v;
3300
+ }
3301
+ function computeIpv4Broadcast(address, netmask) {
3302
+ const a = address.split(".").map((p) => Number.parseInt(p, 10));
3303
+ const m = netmask.split(".").map((p) => Number.parseInt(p, 10));
3304
+ if (a.length !== 4 || m.length !== 4 || a.some((n) => !Number.isInteger(n)) || m.some((n) => !Number.isInteger(n))) {
3305
+ return undefined;
3306
+ }
3307
+ const out = a.map((octet, i) => (octet & m[i]) | (~m[i] & 0xff));
3308
+ return out.join(".");
3309
+ }
3310
+ function ipv4ToInt(host) {
3311
+ const parts = host.split(".");
3312
+ if (parts.length !== 4)
3313
+ return undefined;
3314
+ const oct = parts.map((p) => Number.parseInt(p, 10));
3315
+ if (oct.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
3316
+ return undefined;
3317
+ return ((oct[0] << 24) | (oct[1] << 16) | (oct[2] << 8) | oct[3]) >>> 0;
3318
+ }
3319
+ function netmaskToInt(mask) {
3320
+ return ipv4ToInt(mask);
3321
+ }
3322
+ /**
3323
+ * Enumerate non-loopback IPv4 addresses bound to this host.
3324
+ */
3325
+ function getLocalIpv4Addresses() {
3326
+ const out = [];
3327
+ try {
3328
+ const ifaces = networkInterfaces();
3329
+ for (const list of Object.values(ifaces)) {
3330
+ if (!list)
3331
+ continue;
3332
+ for (const info of list) {
3333
+ if (info.family !== "IPv4" || info.internal)
3334
+ continue;
3335
+ out.push(info.address);
3336
+ }
3337
+ }
3338
+ }
3339
+ catch {
3340
+ // best-effort
3341
+ }
3342
+ return out;
3343
+ }
3344
+ /**
3345
+ * Enumerate non-loopback IPv4 subnets the local machine is attached to.
3346
+ * Used to detect "same-LAN" candidates so we can prefer direct same-subnet
3347
+ * UDP over public addresses that would require NAT hole-punching.
3348
+ */
3349
+ function getLocalIpv4Subnets() {
3350
+ const out = [];
3351
+ try {
3352
+ const ifaces = networkInterfaces();
3353
+ for (const list of Object.values(ifaces)) {
3354
+ if (!list)
3355
+ continue;
3356
+ for (const info of list) {
3357
+ if (info.family !== "IPv4" || info.internal)
3358
+ continue;
3359
+ const addr = ipv4ToInt(info.address);
3360
+ const mask = netmaskToInt(info.netmask);
3361
+ if (addr === undefined || mask === undefined || mask === 0)
3362
+ continue;
3363
+ out.push({ networkBits: (addr & mask) >>> 0, maskBits: mask });
3364
+ }
3365
+ }
3366
+ }
3367
+ catch {
3368
+ // best-effort; no LAN-prefer hint when interface introspection fails
3369
+ }
3370
+ return out;
3371
+ }
3372
+ function isInIpv4Subnet(host, subnet) {
3373
+ const ip = ipv4ToInt(host);
3374
+ if (ip === undefined)
3375
+ return false;
3376
+ return ((ip & subnet.maskBits) >>> 0) === subnet.networkBits;
3377
+ }
3378
+ function isPrivateAddress(host) {
3379
+ // RFC1918 IPv4: 10/8, 172.16/12, 192.168/16; loopback 127/8;
3380
+ // link-local 169.254/16; carrier-grade NAT 100.64/10. IPv6 link-local fe80::/10
3381
+ // and unique-local fc00::/7.
3382
+ if (!host)
3383
+ return false;
3384
+ if (host === "localhost")
3385
+ return true;
3386
+ // IPv6 quick check
3387
+ if (host.includes(":")) {
3388
+ const lower = host.toLowerCase();
3389
+ if (lower === "::1")
3390
+ return true;
3391
+ if (lower.startsWith("fe80:"))
3392
+ return true;
3393
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
3394
+ return true;
3395
+ return false;
3396
+ }
3397
+ // IPv4 dotted quad
3398
+ const parts = host.split(".");
3399
+ if (parts.length !== 4)
3400
+ return false;
3401
+ const oct = parts.map((p) => Number.parseInt(p, 10));
3402
+ if (oct.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
3403
+ return false;
3404
+ if (oct[0] === 10)
3405
+ return true;
3406
+ if (oct[0] === 127)
3407
+ return true;
3408
+ if (oct[0] === 169 && oct[1] === 254)
3409
+ return true;
3410
+ if (oct[0] === 172 && oct[1] >= 16 && oct[1] <= 31)
3411
+ return true;
3412
+ if (oct[0] === 192 && oct[1] === 168)
3413
+ return true;
3414
+ if (oct[0] === 100 && oct[1] >= 64 && oct[1] <= 127)
3415
+ return true;
3416
+ return false;
3417
+ }
3418
+ function readEnvInt(name, fallback) {
3419
+ const raw = process.env[name];
3420
+ if (!raw) {
3421
+ return fallback;
3422
+ }
3423
+ const parsed = Number.parseInt(raw, 10);
3424
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
3425
+ }