@decentnetwork/peer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -0
- package/README.md +97 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +245 -0
- package/dist/compat/address.d.ts +13 -0
- package/dist/compat/address.js +69 -0
- package/dist/compat/bootstrap.d.ts +29 -0
- package/dist/compat/bootstrap.js +178 -0
- package/dist/compat/dht.d.ts +3 -0
- package/dist/compat/dht.js +9 -0
- package/dist/compat/express.d.ts +21 -0
- package/dist/compat/express.js +263 -0
- package/dist/compat/friend.d.ts +4 -0
- package/dist/compat/friend.js +12 -0
- package/dist/compat/net-crypto.d.ts +84 -0
- package/dist/compat/net-crypto.js +278 -0
- package/dist/compat/packet.d.ts +55 -0
- package/dist/compat/packet.js +154 -0
- package/dist/compat/session.d.ts +3 -0
- package/dist/compat/session.js +7 -0
- package/dist/compat/tcp-relay-pool.d.ts +85 -0
- package/dist/compat/tcp-relay-pool.js +342 -0
- package/dist/compat/tcp-relay.d.ts +96 -0
- package/dist/compat/tcp-relay.js +489 -0
- package/dist/compat/text.d.ts +3 -0
- package/dist/compat/text.js +8 -0
- package/dist/compat/tox-dht-crypto.d.ts +18 -0
- package/dist/compat/tox-dht-crypto.js +69 -0
- package/dist/compat/tox-onion.d.ts +66 -0
- package/dist/compat/tox-onion.js +172 -0
- package/dist/crypto/box.d.ts +1 -0
- package/dist/crypto/box.js +3 -0
- package/dist/crypto/keypair.d.ts +5 -0
- package/dist/crypto/keypair.js +37 -0
- package/dist/crypto/nonce.d.ts +1 -0
- package/dist/crypto/nonce.js +1 -0
- package/dist/crypto/sign.d.ts +1 -0
- package/dist/crypto/sign.js +3 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/peer.d.ts +45 -0
- package/dist/peer.js +3425 -0
- package/dist/runtime/errors.d.ts +3 -0
- package/dist/runtime/errors.js +6 -0
- package/dist/runtime/events.d.ts +4 -0
- package/dist/runtime/events.js +1 -0
- package/dist/runtime/lifecycle.d.ts +7 -0
- package/dist/runtime/lifecycle.js +12 -0
- package/dist/store/config.d.ts +2 -0
- package/dist/store/config.js +1 -0
- package/dist/store/friends.d.ts +13 -0
- package/dist/store/friends.js +1 -0
- package/dist/store/state.d.ts +3 -0
- package/dist/store/state.js +1 -0
- package/dist/transport/socket.d.ts +4 -0
- package/dist/transport/socket.js +1 -0
- package/dist/transport/tcp.d.ts +3 -0
- package/dist/transport/tcp.js +5 -0
- package/dist/transport/udp.d.ts +24 -0
- package/dist/transport/udp.js +90 -0
- package/dist/types/bootstrap.d.ts +2 -0
- package/dist/types/bootstrap.js +1 -0
- package/dist/types/dht.d.ts +3 -0
- package/dist/types/dht.js +1 -0
- package/dist/types/friend.d.ts +1 -0
- package/dist/types/friend.js +1 -0
- package/dist/types/message.d.ts +1 -0
- package/dist/types/message.js +1 -0
- package/dist/types/peer.d.ts +51 -0
- package/dist/types/peer.js +1 -0
- package/dist/types/session.d.ts +1 -0
- package/dist/types/session.js +1 -0
- package/dist/utils/base58.d.ts +2 -0
- package/dist/utils/base58.js +51 -0
- package/dist/utils/bytes.d.ts +4 -0
- package/dist/utils/bytes.js +31 -0
- package/docs/INSTALL.md +103 -0
- package/docs/USAGE_GUIDE.md +724 -0
- package/package.json +77 -0
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
|
+
}
|