@decentnetwork/peer 0.1.79 → 0.1.81

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.
@@ -28,6 +28,7 @@ export class LegacyExpressClient {
28
28
  return {
29
29
  host: node.host,
30
30
  port: node.port,
31
+ tls: node.tls !== false, // default HTTPS; opt into HTTP with tls:false
31
32
  sharedKey: nacl.box.before(expressPk, this.#selfKeyPair.secretKey)
32
33
  };
33
34
  });
@@ -152,7 +153,8 @@ export class LegacyExpressClient {
152
153
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
153
154
  }
154
155
  async #http(node, method, path, body) {
155
- const url = `https://${node.host}:${node.port}/${path}`;
156
+ const scheme = node.tls ? "https" : "http";
157
+ const url = `${scheme}://${node.host}:${node.port}/${path}`;
156
158
  const controller = new AbortController();
157
159
  const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
158
160
  try {
@@ -80,5 +80,9 @@ export declare function openCryptoDataPacket(packet: Uint8Array, opts: {
80
80
  bufferStart: number;
81
81
  packetNumber: number;
82
82
  nonceLast2: number;
83
+ /** The exact nonce this packet decrypted with. The caller must track
84
+ * recvBaseNonce = usedNonce (+1 for the next expected) — NOT overwrite
85
+ * just the low two bytes, which loses the carry into byte 21. */
86
+ usedNonce: Uint8Array;
83
87
  } | undefined;
84
88
  export declare function incrementNonce(nonce: Uint8Array): void;
@@ -191,9 +191,27 @@ export function openCryptoDataPacket(packet, opts) {
191
191
  if (packet.length <= CRYPTO_DATA_PACKET_MIN_SIZE || packet[0] !== NET_PACKET_CRYPTO_DATA) {
192
192
  return undefined;
193
193
  }
194
+ // The wire carries only the low 16 bits of the sender's nonce counter.
195
+ // Reconstruct the full nonce like toxcore handle_data_packet: advance a
196
+ // COPY of our tracked nonce by the uint16 difference WITH CARRY into the
197
+ // upper bytes. The old code overwrote the two low bytes in place, which
198
+ // silently dropped the carry whenever packet loss spanned a 64k nonce
199
+ // wrap — from then on EVERY packet failed to decrypt until re-handshake
200
+ // (observed as endless "no session matched" from high-rate peers, whose
201
+ // counters wrap every ~30-60s of CCTV/file traffic).
194
202
  const nonce = opts.recvBaseNonce.slice();
195
- nonce[nonce.length - 2] = packet[1];
196
- nonce[nonce.length - 1] = packet[2];
203
+ const trackedLow = ((nonce[nonce.length - 2] << 8) | nonce[nonce.length - 1]) >>> 0;
204
+ const wireLow = ((packet[1] << 8) | packet[2]) >>> 0;
205
+ const diff = (wireLow - trackedLow) & 0xffff;
206
+ if (diff < 0x8000) {
207
+ addToNonceCounter(nonce, diff);
208
+ }
209
+ else {
210
+ // The wire counter is BEHIND our tracked one — a reordered/duplicate
211
+ // packet from earlier in the stream (dual-path UDP+relay delivery
212
+ // reorders constantly). Step backward with borrow.
213
+ subFromNonceCounter(nonce, 0x10000 - diff);
214
+ }
197
215
  const cipher = packet.slice(3);
198
216
  const plain = nacl.secretbox.open(cipher, nonce, opts.sessionSharedKey);
199
217
  if (!plain || plain.length <= CRYPTO_DATA_PLAIN_HEADER_LENGTH) {
@@ -212,9 +230,29 @@ export function openCryptoDataPacket(packet, opts) {
212
230
  payload: plain.slice(payloadOffset),
213
231
  bufferStart,
214
232
  packetNumber,
215
- nonceLast2: ((packet[1] << 8) | packet[2]) >>> 0
233
+ nonceLast2: ((packet[1] << 8) | packet[2]) >>> 0,
234
+ usedNonce: nonce
216
235
  };
217
236
  }
237
+ /** Add `count` to the big-endian nonce counter with carry (toxcore
238
+ * increment_nonce_number). */
239
+ function addToNonceCounter(nonce, count) {
240
+ let carry = count >>> 0;
241
+ for (let i = nonce.length - 1; i >= 0 && carry > 0; i--) {
242
+ const sum = nonce[i] + (carry & 0xff);
243
+ nonce[i] = sum & 0xff;
244
+ carry = (carry >>> 8) + (sum > 0xff ? 1 : 0);
245
+ }
246
+ }
247
+ /** Subtract `count` from the big-endian nonce counter with borrow. */
248
+ function subFromNonceCounter(nonce, count) {
249
+ let borrow = count >>> 0;
250
+ for (let i = nonce.length - 1; i >= 0 && borrow > 0; i--) {
251
+ const sub = nonce[i] - (borrow & 0xff);
252
+ nonce[i] = sub & 0xff;
253
+ borrow = (borrow >>> 8) + (sub < 0 ? 1 : 0);
254
+ }
255
+ }
218
256
  function createCookie(opts) {
219
257
  const timestamp = BigInt(Math.floor(Date.now() / 1000));
220
258
  const contents = concatBytes([
@@ -23,7 +23,17 @@ import { EventEmitter } from "node:events";
23
23
  import { base58ToBytes } from "../utils/base58.js";
24
24
  import { TcpRelayClient } from "./tcp-relay.js";
25
25
  const KEY_SIZE = 32;
26
- const MAX_RELAY_CONNECTIONS = 3;
26
+ // Connect to (up to) this many bootstrap nodes as persistent TCP relays. It was
27
+ // 3 — but the pool picks the FIRST N in order, so a peer only met friends whose
28
+ // relays happened to be in its own top-3. A NATIVE peer (iOS/Android) reaches us
29
+ // ONLY over a relay we both sit on (OOB cookie handshake); if our top-3 doesn't
30
+ // include the relay it uses, it's stuck "connecting" forever — even though we
31
+ // share the node further down the list (observed: iOS uses sh.callt.net =
32
+ // 47.100.103.201 as its #1 relay, which sat at #7 in our bootstrap so we never
33
+ // connected to it). Covering more of the shared bootstrap set makes the same-
34
+ // relay rendezvous reliable for native peers. Cheap: a handful of extra idle TCP
35
+ // keepalives.
36
+ const MAX_RELAY_CONNECTIONS = 8;
27
37
  const RECONNECT_BASE_MS = 5_000;
28
38
  const RECONNECT_MAX_MS = 120_000;
29
39
  export class TcpRelayPool extends EventEmitter {
package/dist/peer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
3
3
  import { networkInterfaces } from "node:os";
4
- import { dirname } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import nacl from "tweetnacl";
6
6
  import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
7
7
  import { LegacyBootstrapClient } from "./compat/bootstrap.js";
@@ -255,6 +255,22 @@ export class Peer {
255
255
  // unconnected friend (the "accepted friend that never connects" wedge —
256
256
  // requestRoute only fired once at startup and was never retried).
257
257
  #routeRequestCooldown = new Map();
258
+ // The onion route that carried the last successful announce exchange with
259
+ // each announce node ("host:port" -> path | "direct"). A node's ping_id is
260
+ // bound to the SOURCE ip:port it saw the request arrive from (the path's
261
+ // exit hop, or us when direct). Step2 of a self-announce must therefore
262
+ // travel the exact route step1 used — re-selecting a path (scores mutate
263
+ // between calls) or falling back direct changes the source, the node
264
+ // rejects the ping_id, and the announce NEVER stores (observed: zero
265
+ // isStored=2 across an entire run — nobody could find us to learn our
266
+ // announce data key).
267
+ #announceRouteUsed = new Map();
268
+ // Last-known DHT pubkey per friend (identity id -> dht pk). Sessions get
269
+ // deleted on timeout, but relay ROUTING_REQUESTs must keep using the key
270
+ // the friend handshakes relays with — for native peers that's the DHT key,
271
+ // not the identity key. Without this survives-teardown copy, every re-link
272
+ // attempt after a session teardown silently used the wrong key.
273
+ #friendDhtKeys = new Map();
258
274
  // Per-friend cooldown for RE-SENDING the friend-request to a friend that's
259
275
  // never been accepted (still "requested"). On reconnect / restart we must
260
276
  // re-offer — the original request can be lost while the friend was offline.
@@ -290,6 +306,11 @@ export class Peer {
290
306
  // #initiateSession every few seconds for every friend; without this
291
307
  // dedupe, lower-pubkey side floods the log with "deferring to peer".
292
308
  #initiateSkipLogged = new Set();
309
+ // When we FIRST deferred initiation to a higher-pubkey peer (per friend).
310
+ // If the peer hasn't established within the grace window we break the
311
+ // defer and initiate ourselves (see #initiateSession). Cleared whenever a
312
+ // session establishes so each outage gets a fresh window.
313
+ #initiateDeferSinceMs = new Map();
293
314
  // Per-friend last time we deleted a desynced session (rate-limit; survives the
294
315
  // session deletion, unlike a field on the session object itself).
295
316
  #lastDesyncDeleteMs = new Map();
@@ -336,11 +357,19 @@ export class Peer {
336
357
  keyPair: this.#keyPair,
337
358
  transport: this.#udp
338
359
  });
339
- const announceGenerated = createEphemeralKeyPair();
340
- this.#announceDataKey = {
341
- publicKey: announceGenerated.publicKey,
342
- secretKey: announceGenerated.secretKey
343
- };
360
+ // The announce data key MUST survive restarts. Native toxcore peers cache
361
+ // our last-known data pk and keep encrypting onion data (DHT-PK announces,
362
+ // friend requests) to it until they re-discover our announce on the DHT —
363
+ // which can take a long time when our self-announce store rate is weak.
364
+ // With an ephemeral per-run key, every restart silently invalidated every
365
+ // friend's cached key: 100% of inbound onion data failed to decrypt
366
+ // ("onion data response decrypt failed"), so we never learned a native
367
+ // friend's UDP endpoint or current relay list, and a dropped relay route
368
+ // could never self-heal (the iOS online-flap bug).
369
+ {
370
+ const announceKeyPath = join(dirname(this.#opts.keyFile), "announce-data-key.json");
371
+ this.#announceDataKey = await loadOrCreateKeyPair(announceKeyPath);
372
+ }
344
373
  this.#cookieSymmetricKey = randomBytes(32);
345
374
  await this.#loadPersistedFriends();
346
375
  if (this.#opts.expressNodes && this.#opts.expressNodes.length > 0) {
@@ -390,15 +419,23 @@ export class Peer {
390
419
  this.#handleTcpDatagram(friendKey, payload);
391
420
  });
392
421
  this.#tcpRelays.on("friendOnline", (friendKey) => {
393
- const friendId = carrierIdFromPublicKey(friendKey);
394
- this.#debugLog(`tcp_relay friend_online ${friendId}`);
422
+ // The pool reports the key the peer handshook the relay with — for
423
+ // native peers that's the DHT key. Map it to the real friend id so
424
+ // route state + session kick land on the REAL session, not a ghost.
425
+ const friendId = this.#friendIdForPoolKey(friendKey);
426
+ this.#debugLog(`tcp_relay friend_online ${carrierIdFromPublicKey(friendKey)} (friend=${friendId})`);
395
427
  // Mark the (existing or new) session as having a TCP route so
396
428
  // outbound goes through the relay even if no UDP endpoint is
397
429
  // known. Then kick #initiateSession — this issues a cookie
398
430
  // request that will travel via TCP if no UDP path exists.
399
431
  const session = this.#friendSessions.get(friendId) ?? this.#newSessionShell();
400
432
  session.hasTcpRoute = true;
401
- session.friendRealPublicKey ??= new Uint8Array(friendKey);
433
+ if (friendId === carrierIdFromPublicKey(friendKey)) {
434
+ session.friendRealPublicKey ??= new Uint8Array(friendKey);
435
+ }
436
+ else {
437
+ session.friendDhtPublicKey ??= new Uint8Array(friendKey);
438
+ }
402
439
  this.#friendSessions.set(friendId, session);
403
440
  if (!session.established) {
404
441
  // A fresh relay-online signal means the peer just (re)connected —
@@ -413,12 +450,18 @@ export class Peer {
413
450
  }
414
451
  });
415
452
  this.#tcpRelays.on("friendOffline", (friendKey) => {
416
- const friendId = carrierIdFromPublicKey(friendKey);
453
+ const friendId = this.#friendIdForPoolKey(friendKey);
417
454
  const session = this.#friendSessions.get(friendId);
418
455
  if (session) {
419
456
  session.hasTcpRoute = false;
420
457
  }
421
- this.#debugLog(`tcp_relay friend_offline ${friendId}`);
458
+ this.#debugLog(`tcp_relay friend_offline ${carrierIdFromPublicKey(friendKey)} (friend=${friendId})`);
459
+ // Re-assert the route right away (idempotent). A native peer that
460
+ // blips off a relay (network switch, app background, relay rotation)
461
+ // reconnects seconds later — with our ROUTING_REQUEST already parked,
462
+ // the relay re-links the moment it returns and fires friendOnline,
463
+ // instead of the session sitting pathless until the periodic sweep.
464
+ this.#tcpRelays?.requestRoute(friendKey);
422
465
  });
423
466
  this.#tcpRelays.on("status", (connected, total) => {
424
467
  this.#debugLog(`tcp_pool ${connected}/${total} relays connected`);
@@ -465,14 +508,25 @@ export class Peer {
465
508
  this.#debugLog(`tcp pool initial start failed: ${error.message}`);
466
509
  });
467
510
  // Pre-route every persisted friend so the pool watches for them
468
- // the moment a relay is up.
469
- for (const friend of this.#friends.values()) {
511
+ // the moment a relay is up — under BOTH keys: identity (JS peers)
512
+ // and the persisted DHT key (native peers handshake relays with it).
513
+ for (const [friendId, friend] of this.#friends.entries()) {
470
514
  try {
471
515
  const pk = base58ToBytes(friend.pubkey);
472
516
  if (pk.length === 32)
473
517
  this.#tcpRelays.requestRoute(pk);
474
518
  }
475
519
  catch { /* skip malformed */ }
520
+ if (friend.dhtPubkey && friend.dhtPubkey !== friend.pubkey) {
521
+ try {
522
+ const dhtPk = base58ToBytes(friend.dhtPubkey);
523
+ if (dhtPk.length === 32) {
524
+ this.#friendDhtKeys.set(friendId, dhtPk);
525
+ this.#tcpRelays.requestRoute(dhtPk);
526
+ }
527
+ }
528
+ catch { /* skip malformed */ }
529
+ }
476
530
  }
477
531
  }
478
532
  this.#started = true;
@@ -905,10 +959,25 @@ export class Peer {
905
959
  // (UDP via session.remote, or TCP relay via session.hasTcpRoute).
906
960
  // #sendMessengerPacket internally fans out to both transports and
907
961
  // returns true if at least one accepted; throws only if zero did.
962
+ //
963
+ // CRUCIAL: also require the session to be PROVEN — we've decrypted a real
964
+ // packet from the peer within FRIEND_TIMEOUT_MS. `established` alone can be a
965
+ // half-open / key-desynced session (common JS<->native case): our transport
966
+ // ACCEPTS the encrypted packet (so #sendMessengerPacket returns success, no
967
+ // throw) but the peer can't decrypt it, so the message is silently swallowed
968
+ // — and because the direct send "succeeded", the express fallback below never
969
+ // runs. Observed: an offline message to a desynced iOS peer showed "sent"
970
+ // yet never reached the peer OR the express relay (0 rows). Gating on recent
971
+ // inbound routes such sends to express store-and-forward instead of losing
972
+ // them. A genuinely-healthy session proves itself within a ping RTT (~4-8s),
973
+ // so at most the first message of a cold conversation takes the express path.
974
+ const provenInbound = session?.lastPingRecvMs !== undefined &&
975
+ Date.now() - session.lastPingRecvMs < FRIEND_TIMEOUT_MS;
908
976
  const liveSession = session?.established
909
977
  && session.sessionSharedKey
910
978
  && session.ourBaseNonce
911
- && (session.remote || session.hasTcpRoute);
979
+ && (session.remote || session.hasTcpRoute)
980
+ && provenInbound;
912
981
  if (liveSession) {
913
982
  try {
914
983
  await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, packet);
@@ -1136,21 +1205,61 @@ export class Peer {
1136
1205
  * starts with `tcp:` so downstream code can recognize the origin
1137
1206
  * and avoid setting `session.remote` to a UDP-shaped value.
1138
1207
  */
1208
+ /** Record a friend's DHT pubkey: in-memory (survives session teardown) and
1209
+ * persisted on the friend record (survives restarts, so start() can park
1210
+ * relay routes under the right key before the friend announces again). */
1211
+ #learnFriendDhtKey(friendId, dhtPublicKey) {
1212
+ if (dhtPublicKey.length !== 32)
1213
+ return;
1214
+ this.#friendDhtKeys.set(friendId, new Uint8Array(dhtPublicKey));
1215
+ const friend = this.#friends.get(friendId);
1216
+ if (friend) {
1217
+ const b58 = carrierIdFromPublicKey(dhtPublicKey);
1218
+ if (friend.dhtPubkey !== b58) {
1219
+ this.#friends.set(friendId, { ...friend, dhtPubkey: b58 });
1220
+ this.#persistFriends();
1221
+ }
1222
+ }
1223
+ }
1224
+ /** Resolve a TCP-relay-pool key (the key the peer handshook the relay
1225
+ * with) to OUR friend id. For JS peers the two coincide; native peers
1226
+ * (iOS/Android) sit on relays under their DHT key — without this
1227
+ * mapping, every pool event (friendOnline/friendOffline/inbound data
1228
+ * bookkeeping) landed on a GHOST session keyed by the DHT key while the
1229
+ * real session (keyed by identity) never saw route state change. */
1230
+ #friendIdForPoolKey(poolKey) {
1231
+ const asId = carrierIdFromPublicKey(poolKey);
1232
+ if (this.#friends.has(asId))
1233
+ return asId;
1234
+ for (const [friendId, dhtPk] of this.#friendDhtKeys) {
1235
+ if (bytesEqual(dhtPk, poolKey))
1236
+ return friendId;
1237
+ }
1238
+ return asId;
1239
+ }
1139
1240
  #handleTcpDatagram(friendKey, payload) {
1140
- const friendId = carrierIdFromPublicKey(friendKey);
1141
- // Mark the session as TCP-routed if we don't have it yet.
1241
+ const poolId = carrierIdFromPublicKey(friendKey);
1242
+ // Route-state bookkeeping belongs on the REAL friend's session (mapped
1243
+ // via the DHT key for native peers), not a ghost keyed by the pool key.
1244
+ const friendId = this.#friendIdForPoolKey(friendKey);
1142
1245
  let session = this.#friendSessions.get(friendId);
1143
1246
  if (!session) {
1144
1247
  session = this.#newSessionShell();
1145
- session.friendRealPublicKey = new Uint8Array(friendKey);
1146
1248
  this.#friendSessions.set(friendId, session);
1147
1249
  }
1148
1250
  session.hasTcpRoute = true;
1149
- session.friendRealPublicKey ??= new Uint8Array(friendKey);
1150
- // Feed through the regular dispatcher with synthetic remote.
1251
+ if (friendId !== poolId) {
1252
+ session.friendDhtPublicKey ??= new Uint8Array(friendKey);
1253
+ }
1254
+ else {
1255
+ session.friendRealPublicKey ??= new Uint8Array(friendKey);
1256
+ }
1257
+ // Feed through the regular dispatcher with synthetic remote. Keep the
1258
+ // POOL key in the remote tag: the crypto layer matched sessions by this
1259
+ // exact string during the (proven-working) handshake flow.
1151
1260
  this.#onDatagram({
1152
1261
  data: Buffer.from(payload),
1153
- remote: { address: `tcp:${friendId}`, port: 0 }
1262
+ remote: { address: `tcp:${poolId}`, port: 0 }
1154
1263
  });
1155
1264
  }
1156
1265
  /** True when the synthetic remote was constructed by #handleTcpDatagram. */
@@ -1250,6 +1359,15 @@ export class Peer {
1250
1359
  this.#debugLog(`cookie response: no tcp relay accepted oob send for ${senderId}`);
1251
1360
  }
1252
1361
  }
1362
+ else if (this.#isUnroutableSelfSource(remote.address, remote.port)) {
1363
+ // The request arrived sourced from our own TUN address (or our own
1364
+ // socket) — an overlay routing artifact. Replying there loops into
1365
+ // ourselves and dies. Answer over the relay OOB path instead, which
1366
+ // the peer reliably receives. (A self PHYSICAL address with a
1367
+ // different port is a same-host peer and gets the normal UDP reply.)
1368
+ const sent = this.#tcpRelays?.sendOobToFriend(request.senderDhtPublicKey, response) ?? 0;
1369
+ this.#debugLog(`cookie response: source ${remote.address}:${remote.port} is an unroutable self-source — replied via tcp_oob instead (relays=${sent})`);
1370
+ }
1253
1371
  else {
1254
1372
  void this.#sendPacket(response, { host: remote.address, port: remote.port })
1255
1373
  .then(() => {
@@ -1431,6 +1549,9 @@ export class Peer {
1431
1549
  if (!state.friendDhtPublicKey)
1432
1550
  state.friendDhtPublicKey = hs.senderDhtPublicKey;
1433
1551
  }
1552
+ if (hs.senderDhtPublicKey && hs.senderDhtPublicKey.length === 32) {
1553
+ this.#learnFriendDhtKey(friendId, hs.senderDhtPublicKey);
1554
+ }
1434
1555
  const wasEstablished = state.established === true;
1435
1556
  // Detect a real session reset: the peer is offering NEW session
1436
1557
  // keys (different ephemeral session pubkey from before). When
@@ -1479,14 +1600,25 @@ export class Peer {
1479
1600
  // the current session is dead — fall through and accept the new one.
1480
1601
  const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
1481
1602
  const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
1482
- if (sinceEstablished > 1000 && currentSessionAlive) {
1483
- // Reject a new-key handshake while our session is alive toxcore does
1484
- // the same. (The "half-open breaker" that accepted these after N tries
1485
- // was reverted: accepting a new key ROTATES the session, which churned
1486
- // healthy sessions; the real fix was the TURN-crash fix, not this.)
1603
+ // The peer has RE-KEYED if it's sending us crypto data we can't decrypt
1604
+ // more recently than the last packet we could — our "alive" signal above
1605
+ // is stale (it counts packets from just before the peer flapped). This is
1606
+ // the flapping iOS/native case: iOS drops, re-handshakes with a fresh
1607
+ // session key, then sends online messages we can't decrypt because we're
1608
+ // still holding the dead keys and rejecting its fix-up handshake for the
1609
+ // whole 32s timeout. Accept the fresh handshake NOW to resync. Safe: a
1610
+ // healthy session never produces undecryptable data, so peerReKeyed stays
1611
+ // false for it — this can't churn a live session (the reverted "half-open
1612
+ // breaker" churned because it accepted new keys with NO such evidence).
1613
+ const peerReKeyed = state.undecryptableRecvMs !== undefined &&
1614
+ state.undecryptableRecvMs > (state.lastPingRecvMs ?? 0);
1615
+ if (sinceEstablished > 1000 && currentSessionAlive && !peerReKeyed) {
1487
1616
  this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
1488
1617
  return;
1489
1618
  }
1619
+ if (peerReKeyed) {
1620
+ this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (peer re-keyed: undecryptable data since last good packet)`);
1621
+ }
1490
1622
  this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
1491
1623
  `lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
1492
1624
  }
@@ -1495,6 +1627,10 @@ export class Peer {
1495
1627
  state.sessionSharedKey = nacl.box.before(hs.sessionPublicKey, state.ourSessionKeyPair.secretKey);
1496
1628
  state.established = true;
1497
1629
  state.sessionEstablishedAtMs = Date.now();
1630
+ // Fresh session up — reset the higher-pubkey defer window for the
1631
+ // next outage (see #initiateSession).
1632
+ this.#initiateDeferSinceMs.delete(friendId);
1633
+ this.#initiateSkipLogged.delete(friendId);
1498
1634
  if (this.#remoteIsTcp(remote)) {
1499
1635
  state.hasTcpRoute = true;
1500
1636
  // Don't set state.remote — there's no UDP endpoint for this peer.
@@ -1505,6 +1641,11 @@ export class Peer {
1505
1641
  if (isNewSession) {
1506
1642
  state.sendPacketNumber = 0;
1507
1643
  state.receiveBufferStart = 0;
1644
+ // Packet numbers restart at 0 — parked retransmit payloads from
1645
+ // the previous session are meaningless (and would resend under
1646
+ // colliding numbers). Drop them.
1647
+ state.sendArray = undefined;
1648
+ state.sendBufferStartNum = undefined;
1508
1649
  }
1509
1650
  else {
1510
1651
  state.sendPacketNumber ??= 0;
@@ -1670,6 +1811,13 @@ export class Peer {
1670
1811
  }
1671
1812
  state.lastRelayRecvMs = Date.now();
1672
1813
  }
1814
+ else if (this.#isUnroutableSelfSource(remote.address, remote.port)) {
1815
+ // Sourced from our own TUN address / own socket (overlay routing
1816
+ // artifact): the payload is real — deliver it — but this is NOT a
1817
+ // usable bidirectional UDP path. Don't adopt it (replies loop into
1818
+ // our own interface) and don't mark UDP-confirmed, or the punch
1819
+ // machinery stops trying to establish a REAL direct path.
1820
+ }
1673
1821
  else {
1674
1822
  // Adopt the source as our send endpoint — via #adoptRemote, which
1675
1823
  // refuses to downgrade off a locked physical-LAN path to a public /
@@ -1688,12 +1836,44 @@ export class Peer {
1688
1836
  if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
1689
1837
  continue;
1690
1838
  }
1691
- state.peerBaseNonce[state.peerBaseNonce.length - 2] = packet[1];
1692
- state.peerBaseNonce[state.peerBaseNonce.length - 1] = packet[2];
1693
- incrementNonce(state.peerBaseNonce);
1694
- state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
1839
+ // Track the receive nonce from the EXACT nonce this packet used
1840
+ // (openCryptoDataPacket reconstructs it with full carry/borrow).
1841
+ // The old two-byte overwrite dropped the carry into byte 21 when
1842
+ // loss spanned a 64k counter wrap, permanently desyncing the
1843
+ // session. Only move FORWARD — a late reordered packet must not
1844
+ // drag the tracked nonce backward.
1845
+ {
1846
+ const trackedLow = ((state.peerBaseNonce[state.peerBaseNonce.length - 2] << 8) | state.peerBaseNonce[state.peerBaseNonce.length - 1]) >>> 0;
1847
+ const fwd = ((opened.nonceLast2 - trackedLow) & 0xffff) < 0x8000;
1848
+ if (fwd) {
1849
+ state.peerBaseNonce = opened.usedNonce.slice();
1850
+ incrementNonce(state.peerBaseNonce);
1851
+ }
1852
+ }
1695
1853
  const kind = opened.payload[0];
1696
1854
  const inner = opened.payload.slice(1);
1855
+ // Advance our ack point ONLY for kinds that actually consume a
1856
+ // reliable packet number on the sender. Toxcore sends REQUEST(1)
1857
+ // and lossy (>=192) packets with num = send_array.buffer_end — the
1858
+ // NEXT UNUSED number, not stored in its send array. Counting those
1859
+ // advanced our receiveBufferStart to buffer_end+1, i.e. we then
1860
+ // acked one more packet than the peer ever sent — and toxcore's
1861
+ // handle_data_packet_core REJECTS any packet whose buffer_start is
1862
+ // out of its send window (clear_buffer_until -> return -1). Result:
1863
+ // from the peer's FIRST retransmit request onward, every packet we
1864
+ // sent was discarded whole (fresh and retransmitted alike) and the
1865
+ // peer went deaf until it re-keyed — the final piece of the iOS
1866
+ // session churn. The JS droppable bulk channel (bulkDataPacketId)
1867
+ // reuses the current number without consuming it, so it must not
1868
+ // advance the ack point either (also fixes reliable packets being
1869
+ // dropped as duplicates after a droppable burst under the same
1870
+ // number).
1871
+ const consumesReliableNumber = kind !== PACKET_ID_REQUEST &&
1872
+ !(kind !== undefined && kind >= 192) &&
1873
+ kind !== this.#opts.bulkDataPacketId;
1874
+ if (consumesReliableNumber) {
1875
+ state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
1876
+ }
1697
1877
  // Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
1698
1878
  if (this.#fileTransfer.handlePacket(friendId, kind, inner))
1699
1879
  return;
@@ -1729,9 +1909,13 @@ export class Peer {
1729
1909
  return;
1730
1910
  }
1731
1911
  if (kind === PACKET_ID_REQUEST) {
1732
- // Lossless retransmission request from peer. Full reliable stream
1733
- // not yet implemented in JS port, so just acknowledge by logging.
1734
- this.#debugVerboseLog(`crypto request packet received from ${friendId} (no retransmit queue)`);
1912
+ // Lossless retransmission request (toxcore handle_request_packet).
1913
+ // Bytes are 1-based deltas walking our send window from
1914
+ // sendBufferStartNum: a matching delta marks that packet number
1915
+ // REQUESTED (peer is missing it — resend); every walked number
1916
+ // that doesn't match is implicitly ACKED (peer has it — drop from
1917
+ // the buffer). 0 is a +255 filler.
1918
+ this.#handleRetransmitRequest(friendId, state, inner);
1735
1919
  return;
1736
1920
  }
1737
1921
  if (kind === PACKET_ID_SHARE_RELAYS) {
@@ -1820,7 +2004,8 @@ export class Peer {
1820
2004
  this.#setFriendOnline(friendId, remote.address, remote.port);
1821
2005
  this.#events.emit("text", {
1822
2006
  pubkey: friendId,
1823
- text
2007
+ text,
2008
+ via: "online"
1824
2009
  });
1825
2010
  this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
1826
2011
  return;
@@ -1852,6 +2037,25 @@ export class Peer {
1852
2037
  // CRASH (TURN DNS); with that fixed, sessions stay converged on their own.
1853
2038
  // Just drop the undecryptable packet — net_crypto's normal re-handshake
1854
2039
  // path recovers a genuinely dead session.
2040
+ // If this came over the TCP relay (synthetic tcp:<friendId> remote) we KNOW
2041
+ // which friend sent it. Record the decrypt failure so the handshake path can
2042
+ // distinguish a re-keyed peer (desync — accept its fresh handshake now) from
2043
+ // a live one, instead of holding dead keys for the full timeout while every
2044
+ // online message is silently dropped.
2045
+ if (remote.address.startsWith("tcp:")) {
2046
+ // remote carries the POOL key id (native peers: DHT key) — map to
2047
+ // the real friend so the desync marker lands on the real session.
2048
+ let desyncFriendId = remote.address.slice(4);
2049
+ try {
2050
+ desyncFriendId = this.#friendIdForPoolKey(base58ToBytes(desyncFriendId));
2051
+ }
2052
+ catch { /* keep raw id */ }
2053
+ const ds = this.#friendSessions.get(desyncFriendId);
2054
+ if (ds?.established) {
2055
+ ds.undecryptableRecvMs = Date.now();
2056
+ this.#debugVerboseLog(`undecryptable crypto data from ${desyncFriendId} (peer likely re-keyed)`);
2057
+ }
2058
+ }
1855
2059
  this.#debugVerboseLog(`crypto data received from ${remote.address}:${remote.port} but no session matched`);
1856
2060
  return;
1857
2061
  }
@@ -1949,6 +2153,7 @@ export class Peer {
1949
2153
  session.lastDhtPkNoReplay = noReplay;
1950
2154
  session.friendRealPublicKey ??= senderPublicKey;
1951
2155
  session.friendDhtPublicKey = friendDhtPublicKey;
2156
+ this.#learnFriendDhtKey(senderId, friendDhtPublicKey);
1952
2157
  // A DHT-PK announce from this friend IS the proof they've accepted
1953
2158
  // our friend request — only friends actively trying to reach us send
1954
2159
  // these. Mark acceptedAt so subsequent peer.sendFriendRequest calls
@@ -2165,7 +2370,7 @@ export class Peer {
2165
2370
  // Malformed pubkey, fall through.
2166
2371
  }
2167
2372
  }
2168
- this.#events.emit("text", { pubkey: fromUserId, text });
2373
+ this.#events.emit("text", { pubkey: fromUserId, text, via: "offline" });
2169
2374
  }
2170
2375
  catch {
2171
2376
  // Ignore invalid offline payloads.
@@ -2459,7 +2664,10 @@ export class Peer {
2459
2664
  dataPublicKey: this.#announceDataKey.publicKey,
2460
2665
  sendBack: c.sendBack,
2461
2666
  allowDirectFallback: true,
2462
- attempts: SELF_ANNOUNCE_ATTEMPTS
2667
+ attempts: SELF_ANNOUNCE_ATTEMPTS,
2668
+ // ping_id is source-bound: step2 must exit from the same hop
2669
+ // step1 did or the node rejects it and nothing ever stores.
2670
+ reuseRoute: true
2463
2671
  })));
2464
2672
  for (let j = 0; j < step1Hits.length; j++) {
2465
2673
  const { c, resp1 } = step1Hits[j];
@@ -2474,6 +2682,7 @@ export class Peer {
2474
2682
  catch { /* best-effort */ }
2475
2683
  if (final.isStored === 2) {
2476
2684
  storedNodes.push(c.node);
2685
+ this.#debugLog(`self announce STORED on ${c.node.host}:${c.node.port} (total ${storedNodes.length})`);
2477
2686
  // Keep dhtHealth.selfAnnounceStoredOn live within the loop,
2478
2687
  // not just at the end, so we see growth in real time.
2479
2688
  this.#lastSelfAnnounceStoredCount = storedNodes.length;
@@ -2564,8 +2773,17 @@ export class Peer {
2564
2773
  // sessions a long grace so a blackout doesn't churn them via re-handshake;
2565
2774
  // unproven sessions still time out fast to self-heal a wedged handshake.
2566
2775
  const proven = session.lastPingRecvMs !== undefined;
2567
- const deadline = proven ? PROVEN_SESSION_HARD_TIMEOUT_MS : FRIEND_TIMEOUT_MS;
2568
- if (proven && silentFor > FRIEND_TIMEOUT_MS && silentFor <= deadline) {
2776
+ // A relay-only session (synthetic tcp: remote, no UDP path) whose
2777
+ // relay route is gone has NO transport at all — nothing can arrive
2778
+ // and nothing we send leaves. The proven-session blackout grace
2779
+ // exists for UDP paths that black out (GFW) while both ends keep
2780
+ // keys; it must NOT keep a transportless relay session as a 180s
2781
+ // zombie (iOS flap: peer sees us vanish while we still show them
2782
+ // online and drop every send). Fast-track those to teardown.
2783
+ const relayOnly = !session.remote || session.remote.host.startsWith("tcp:");
2784
+ const pathless = relayOnly && !session.hasTcpRoute;
2785
+ const deadline = proven && !pathless ? PROVEN_SESSION_HARD_TIMEOUT_MS : FRIEND_TIMEOUT_MS;
2786
+ if (proven && !pathless && silentFor > FRIEND_TIMEOUT_MS && silentFor <= deadline) {
2569
2787
  // Blackout grace: keep the proven session and keep probing (the
2570
2788
  // keepalive + UDP re-punch + relay-keepalive below all still run). Do
2571
2789
  // NOT delete or re-handshake — that's what desyncs and churns. The peer,
@@ -2587,6 +2805,11 @@ export class Peer {
2587
2805
  const pk = base58ToBytes(friend.pubkey);
2588
2806
  if (pk.length === 32)
2589
2807
  this.#tcpRelays?.requestRoute(pk);
2808
+ // Native peers sit on relays under their DHT key — re-request
2809
+ // that too or the relay never re-links after this teardown.
2810
+ const dhtPk = this.#friendDhtKeys.get(friendId);
2811
+ if (dhtPk && dhtPk.length === 32)
2812
+ this.#tcpRelays?.requestRoute(dhtPk);
2590
2813
  }
2591
2814
  catch { /* skip malformed */ }
2592
2815
  continue;
@@ -2774,30 +2997,46 @@ export class Peer {
2774
2997
  // bridged them, cookie+handshake fired once, didn't complete, and
2775
2998
  // nothing re-tried it. Counting the relay route lets the retry fire.
2776
2999
  const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
2777
- if (!haveEndpoint) {
2778
- // No UDP endpoint AND no relay route yet. For a peer whose DHT
2779
- // self-announce never stored (WSL2, symmetric NAT, restrictive
2780
- // firewall), neither onion discovery nor UDP punch can ever find
2781
- // them the ONLY bootstrap is the TCP relay: ask our relays to
2782
- // route to the friend's pubkey, and when they're also connected to
2783
- // a shared relay the pool fires `friendOnline` -> #initiateSession.
2784
- // requestRoute was only issued once at start(); if the friend or a
2785
- // relay wasn't connected in that instant it was never retried, so an
2786
- // accepted friend could sit at "requested" forever (the WSL-client-
2787
- // never-gets-an-IP bug). Re-assert it here on a cooldown — cheap and
2788
- // idempotent — so it eventually catches once both ends share a relay.
3000
+ // Route parking runs for EVERY non-established friend — deliberately
3001
+ // NOT gated on haveEndpoint. A stale persisted remoteHost (e.g. a
3002
+ // bootstrap ip:port cached by an old bug) counts as an "endpoint" and
3003
+ // used to suppress this block entirely, so the friend was never
3004
+ // watched on any relay and could only ever be reached by spamming
3005
+ // cookies at the dead address (the iOS-simulator-forever-offline bug).
3006
+ // For a peer whose DHT self-announce never stored (WSL2, symmetric
3007
+ // NAT, restrictive firewall), the TCP relay is the ONLY bootstrap:
3008
+ // ask our relays to route to the friend's keys; when both ends share
3009
+ // a relay the pool fires `friendOnline` -> #initiateSession.
3010
+ // Re-asserted on a cooldown — cheap and idempotent.
3011
+ {
2789
3012
  const lastRouteReq = this.#routeRequestCooldown.get(friendId) ?? 0;
2790
3013
  if (this.#tcpRelays && now - lastRouteReq > 15_000) {
2791
3014
  try {
2792
3015
  const pk = base58ToBytes(friend.pubkey);
3016
+ // The relay routes CRYPTO_DATA by the key the peer CONNECTED to the
3017
+ // relay with. For a NATIVE peer (iOS/Android) that's its DHT key,
3018
+ // NOT its identity key — and the send path already routes by
3019
+ // friendDhtPublicKey. Requesting the route ONLY under the identity
3020
+ // key (as before) meant the relay never linked us to the peer: its
3021
+ // handshake arrives OOB (which needs no route), but every continuous
3022
+ // CRYPTO_DATA packet (chat + keepalives) was silently dropped, so the
3023
+ // session came up then went dead in 32s. Request the DHT key too so
3024
+ // the routed path actually establishes; keep the identity key for
3025
+ // toxcore peers where the two match.
3026
+ const dhtPk = this.#friendSessions.get(friendId)?.friendDhtPublicKey ?? this.#friendDhtKeys.get(friendId);
3027
+ if (dhtPk && dhtPk.length === 32) {
3028
+ this.#tcpRelays.requestRoute(dhtPk);
3029
+ }
2793
3030
  if (pk.length === 32) {
2794
3031
  this.#tcpRelays.requestRoute(pk);
2795
3032
  this.#routeRequestCooldown.set(friendId, now);
2796
- this.#debugLog(`re-requesting relay route for unconnected friend ${friendId}`);
3033
+ this.#debugVerboseLog(`re-requesting relay route for unconnected friend ${friendId}${dhtPk ? " (+dht key)" : ""}`);
2797
3034
  }
2798
3035
  }
2799
3036
  catch { /* skip malformed pubkey */ }
2800
3037
  }
3038
+ }
3039
+ if (!haveEndpoint) {
2801
3040
  // Re-SEND the friend-request to a friend we've never had accepted
2802
3041
  // (still "requested"). The original can be lost — the friend was
2803
3042
  // offline/restarting, a relay blipped, or onion had no return path.
@@ -2874,6 +3113,77 @@ export class Peer {
2874
3113
  });
2875
3114
  }
2876
3115
  }
3116
+ /** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
3117
+ * (a) implicitly ACK every walked packet number that isn't requested,
3118
+ * (b) retransmit the requested ones from the send buffer. */
3119
+ #handleRetransmitRequest(friendId, session, data) {
3120
+ const start = session.sendBufferStartNum;
3121
+ const end = session.sendPacketNumber;
3122
+ if (start === undefined || end === undefined || !session.sendArray || session.sendArray.size === 0) {
3123
+ return;
3124
+ }
3125
+ let cursor = 0;
3126
+ let n = 1;
3127
+ const requested = [];
3128
+ for (let i = start; i !== end; i = (i + 1) >>> 0) {
3129
+ if (cursor >= data.length)
3130
+ break;
3131
+ if (n === data[cursor]) {
3132
+ requested.push(i);
3133
+ cursor++;
3134
+ n = 0;
3135
+ }
3136
+ else {
3137
+ // Peer has this one — implicit ack, free it.
3138
+ session.sendArray.delete(i);
3139
+ }
3140
+ if (n === 255) {
3141
+ n = 1;
3142
+ if (data[cursor] !== 0)
3143
+ return; // malformed filler
3144
+ cursor++;
3145
+ }
3146
+ else {
3147
+ n++;
3148
+ }
3149
+ }
3150
+ // Advance the window past the acked prefix.
3151
+ let s = start;
3152
+ while (s !== end && !session.sendArray.has(s))
3153
+ s = (s + 1) >>> 0;
3154
+ session.sendBufferStartNum = s;
3155
+ if (requested.length === 0)
3156
+ return;
3157
+ this.#debugLog(`retransmit request from ${friendId}: resending ${requested.length} packet(s) [${requested.slice(0, 8).join(",")}${requested.length > 8 ? ",…" : ""}]`);
3158
+ void (async () => {
3159
+ for (const num of requested) {
3160
+ const payload = session.sendArray?.get(num);
3161
+ if (!payload)
3162
+ continue;
3163
+ try {
3164
+ await this.#resendReliablePacket(friendId, session, num, payload);
3165
+ }
3166
+ catch { /* transport hiccup — peer will re-request */ }
3167
+ }
3168
+ })();
3169
+ }
3170
+ /** Re-send a parked reliable packet with its ORIGINAL packet number but
3171
+ * the CURRENT nonce counter (toxcore retransmissions do the same — the
3172
+ * receiver reconstructs the nonce from the wire bytes, while the packet
3173
+ * number slots it into the gap in their receive window). */
3174
+ async #resendReliablePacket(friendId, session, packetNumber, payload) {
3175
+ if (!session.established || !session.sessionSharedKey || !session.ourBaseNonce)
3176
+ return;
3177
+ const encrypted = createCryptoDataPacket({
3178
+ sessionSharedKey: session.sessionSharedKey,
3179
+ sentNonce: session.ourBaseNonce,
3180
+ bufferStart: session.receiveBufferStart ?? 0,
3181
+ packetNumber,
3182
+ payload
3183
+ });
3184
+ incrementNonce(session.ourBaseNonce);
3185
+ await this.#sendToFriend(friendId, encrypted, session, false, false);
3186
+ }
2877
3187
  async #sendMessengerPacket(friendId, kind, data) {
2878
3188
  const session = this.#friendSessions.get(friendId);
2879
3189
  if (!session?.established || !session.sessionSharedKey || !session.ourBaseNonce) {
@@ -2892,7 +3202,35 @@ export class Peer {
2892
3202
  payload
2893
3203
  });
2894
3204
  incrementNonce(session.ourBaseNonce);
2895
- session.sendPacketNumber = (packetNumber + 1) >>> 0;
3205
+ // Reliable-sequence hygiene. Only packets we will actually DELIVER reliably
3206
+ // may consume the reliable packet-number sequence. The app's high-volume bulk
3207
+ // channel (bulkDataPacketId, e.g. decentlan IP=163) and toxcore lossy packets
3208
+ // (192-254) are DROPPED under backpressure — if they burn a reliable number
3209
+ // first, the drop punches a PERMANENT hole in the stream. A native (iOS/C)
3210
+ // peer does strict in-order net_crypto delivery and stalls EVERYTHING after a
3211
+ // hole (incl. chat 64), sending PACKET_ID_REQUEST for a retransmit we never do
3212
+ // (no retransmit queue) — silently wedging online chat with native peers.
3213
+ // File transfer is bulk but NEVER dropped (queued reliably), so it keeps its
3214
+ // number. So advance the reliable number for everything EXCEPT the droppable
3215
+ // bulk-IP channel and lossy packets; the crypto nonce still advances above so
3216
+ // decryption stays in sync regardless.
3217
+ const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
3218
+ const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
3219
+ const isDroppable = (isBulkData && !isFileTransfer) || kind >= 192;
3220
+ if (!isDroppable) {
3221
+ session.sendPacketNumber = (packetNumber + 1) >>> 0;
3222
+ // Park the plaintext for retransmission (native peers stall their
3223
+ // whole lossless stream on a gap until we honor PACKET_ID_REQUEST).
3224
+ session.sendArray ??= new Map();
3225
+ session.sendBufferStartNum ??= packetNumber;
3226
+ session.sendArray.set(packetNumber, payload);
3227
+ // Bound the buffer: beyond this window the peer has either acked via
3228
+ // REQUEST packets or the session is beyond saving anyway.
3229
+ while (session.sendArray.size > 1024 && session.sendBufferStartNum !== session.sendPacketNumber) {
3230
+ session.sendArray.delete(session.sendBufferStartNum);
3231
+ session.sendBufferStartNum = (session.sendBufferStartNum + 1) >>> 0;
3232
+ }
3233
+ }
2896
3234
  session.lastPingSentMs = Date.now();
2897
3235
  // Bulk IP-forwarding data (PACKET_ID_MESSAGE) is the high-volume
2898
3236
  // traffic. Once UDP is established it should ride UDP exclusively and
@@ -2925,12 +3263,10 @@ export class Peer {
2925
3263
  // happens to finish before reordering bites. Pinning file transfer to a
2926
3264
  // single transport keeps the chunks in send order. (Reliability across a
2927
3265
  // lossy path still wants a reorder buffer + retransmit — tracked separately.)
2928
- const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
2929
- const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
2930
3266
  // File transfer rides the relay RELIABLY (never dropped under backpressure):
2931
3267
  // a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
2932
3268
  // chunk corrupts the file (no retransmit). So bulk IP data stays droppable
2933
- // while file transfer is queued.
3269
+ // while file transfer is queued. (isBulkData / isFileTransfer computed above.)
2934
3270
  await this.#sendToFriend(friendId, encrypted, session, isBulkData, isFileTransfer);
2935
3271
  }
2936
3272
  /**
@@ -3134,6 +3470,11 @@ export class Peer {
3134
3470
  if (!friend) {
3135
3471
  return;
3136
3472
  }
3473
+ // An unroutable self-source is never the friend's endpoint (see
3474
+ // #adoptRemote) — don't let it poison the in-memory record either.
3475
+ if (this.#isUnroutableSelfSource(host, port)) {
3476
+ return;
3477
+ }
3137
3478
  // Important: do NOT persist this candidate to disk. Speculative endpoint
3138
3479
  // hints (from DHT-PK extras, sendnodes-derived knownNodes, etc.) are
3139
3480
  // frequently stale relay addresses that aren't actually the friend's
@@ -3166,6 +3507,7 @@ export class Peer {
3166
3507
  }
3167
3508
  if (dhtPublicKey) {
3168
3509
  session.friendDhtPublicKey = dhtPublicKey;
3510
+ this.#learnFriendDhtKey(friendId, dhtPublicKey);
3169
3511
  }
3170
3512
  }
3171
3513
  /** Single choke point for setting session.remote (the direct-UDP send
@@ -3176,7 +3518,26 @@ export class Peer {
3176
3518
  * is just a string compare and is safe to call from the per-packet hot path.
3177
3519
  * Replacing the per-packet getPhysicalLanSubnets() call here was the fix for
3178
3520
  * the CCTV CPU regression. */
3521
+ /** True when replying to host:port would never reach a peer: the host is
3522
+ * one of our own TUN/overlay addresses (replies re-enter the overlay
3523
+ * router and loop), or it's literally our own UDP socket. A self
3524
+ * PHYSICAL address with a DIFFERENT port is routable — that's a
3525
+ * same-host peer (e.g. the iOS simulator running on this Mac). */
3526
+ #isUnroutableSelfSource(host, port) {
3527
+ if (isOwnVirtualAddress(host))
3528
+ return true;
3529
+ if (!isOwnAddress(host))
3530
+ return false;
3531
+ const ourPort = this.#udp?.localPort();
3532
+ return ourPort !== undefined && port === ourPort;
3533
+ }
3179
3534
  #adoptRemote(session, host, port) {
3535
+ // NEVER adopt an unroutable self-source as a friend's remote — replies
3536
+ // would loop into our own interface and die, the peer never hears back
3537
+ // and re-handshakes forever.
3538
+ if (this.#isUnroutableSelfSource(host, port)) {
3539
+ return;
3540
+ }
3180
3541
  if (session.lanRemoteHost &&
3181
3542
  session.remote?.host === session.lanRemoteHost &&
3182
3543
  host !== session.lanRemoteHost) {
@@ -3685,13 +4046,33 @@ export class Peer {
3685
4046
  }
3686
4047
  }
3687
4048
  if (pkCmp < 0) {
3688
- // We're the responder side — wait for peer's COOKIE_REQUEST.
3689
- // Log once per friend to avoid noise from repeated probe calls.
3690
- if (!this.#initiateSkipLogged.has(friendId)) {
3691
- this.#initiateSkipLogged.add(friendId);
3692
- this.#debugLog(`initiate session: deferring to higher-pubkey peer ${friendId}`);
4049
+ // We're the responder side — wait for peer's COOKIE_REQUEST...
4050
+ // but NOT forever. A higher-pubkey peer that still holds a proven
4051
+ // session from before OUR restart never re-initiates (its side looks
4052
+ // healthy; only its data — encrypted with keys we no longer have —
4053
+ // arrives here as an endless undecryptable flood). Deferring
4054
+ // unconditionally is a PERMANENT deadlock: observed live as a dozen
4055
+ // fleet peers "online at the relay, spraying undecryptable data,
4056
+ // never reconnecting" after a mac-dev restart. Toxcore's tie-break
4057
+ // assumes both sides know the session is dead; here only WE do. So:
4058
+ // defer for a grace window; if the peer hasn't established by then,
4059
+ // initiate anyway — the duplicate-handshake fast path and the
4060
+ // re-handshake acceptance rules handle the both-initiate races the
4061
+ // defer was protecting against.
4062
+ const firstDefer = this.#initiateDeferSinceMs.get(friendId);
4063
+ const now = Date.now();
4064
+ if (firstDefer === undefined) {
4065
+ this.#initiateDeferSinceMs.set(friendId, now);
4066
+ }
4067
+ if (firstDefer === undefined || now - firstDefer < 30_000) {
4068
+ // Log once per friend to avoid noise from repeated probe calls.
4069
+ if (!this.#initiateSkipLogged.has(friendId)) {
4070
+ this.#initiateSkipLogged.add(friendId);
4071
+ this.#debugLog(`initiate session: deferring to higher-pubkey peer ${friendId}`);
4072
+ }
4073
+ return false;
3693
4074
  }
3694
- return false;
4075
+ this.#debugLog(`initiate session: higher-pubkey peer ${friendId} hasn't established in ${Math.round((now - firstDefer) / 1000)}s — breaking defer, initiating`);
3695
4076
  }
3696
4077
  // Prefer DHT key learned from DHTPK updates; fallback to real key.
3697
4078
  const friendDhtPk = session?.friendDhtPublicKey ?? friendRealPk;
@@ -4229,26 +4610,22 @@ export class Peer {
4229
4610
  if (ourUdpPort && ourUdpPort > 0) {
4230
4611
  const ourDhtPk = this.#keyPair?.publicKey;
4231
4612
  if (ourDhtPk && ourDhtPk.length === 32) {
4232
- // Enumerate non-loopback IPv4 addresses on this host.
4233
- // We use os.networkInterfaces() rather than guessing — this
4234
- // covers a multi-homed peer with several reachable IPs.
4613
+ // Enumerate non-loopback IPv4 addresses on this host — PHYSICAL
4614
+ // interfaces only (getPhysicalLanAddresses applies VIRTUAL_IFACE_RE).
4615
+ // The old raw networkInterfaces() walk also advertised our own TUN
4616
+ // address (agentnet0's 10.86.x.y): native peers then sent their UDP
4617
+ // cookie/handshake/session packets to our VIRTUAL ip — into the TUN
4618
+ // maze — and we replied to that same poisoned source, so neither
4619
+ // side's packets arrived and the peer saw us flap online/offline
4620
+ // forever (the iOS-simulator churn).
4235
4621
  try {
4236
- const ifaces = (await import("os")).networkInterfaces();
4237
4622
  const seenIps = new Set();
4238
- for (const iface of Object.values(ifaces)) {
4239
- if (!iface)
4240
- continue;
4241
- for (const addr of iface) {
4242
- if (addr.family !== "IPv4")
4243
- continue;
4244
- if (addr.internal)
4623
+ for (const address of getPhysicalLanAddresses()) {
4624
+ {
4625
+ if (seenIps.has(address))
4245
4626
  continue;
4246
- if (addr.address === "0.0.0.0")
4247
- continue;
4248
- if (seenIps.has(addr.address))
4249
- continue;
4250
- seenIps.add(addr.address);
4251
- const parts = addr.address
4627
+ seenIps.add(address);
4628
+ const parts = address
4252
4629
  .split(".")
4253
4630
  .map((p) => Number.parseInt(p, 10));
4254
4631
  if (parts.length !== 4 || parts.some((n) => !(n >= 0 && n <= 255)))
@@ -4429,32 +4806,53 @@ export class Peer {
4429
4806
  dataPublicKey: opts.dataPublicKey,
4430
4807
  sendBack: opts.sendBack
4431
4808
  });
4432
- const attempts = opts.attempts ?? 3;
4433
- for (let attempt = 0; attempt < attempts; attempt++) {
4809
+ const nodeId = `${opts.node.host}:${opts.node.port}`;
4810
+ const pinnedRoute = opts.reuseRoute ? this.#announceRouteUsed.get(nodeId) : undefined;
4811
+ const exchange = async (forcedPath) => {
4434
4812
  const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
4435
4813
  requesterSecretKey: opts.senderSecretKey,
4436
4814
  nodePublicKey: opts.nodePublicKey
4437
4815
  });
4438
- await this.#sendThroughOnionPath(request, opts.node, attempt);
4816
+ const routeUsed = await this.#sendThroughOnionPath(request, opts.node, 0, forcedPath);
4439
4817
  const response = await waiter;
4440
4818
  if (response) {
4441
- this.#recordNodeSuccess(`${opts.node.host}:${opts.node.port}`);
4819
+ this.#recordNodeSuccess(nodeId);
4820
+ this.#announceRouteUsed.set(nodeId, routeUsed);
4442
4821
  return response;
4443
4822
  }
4444
- this.#recordNodeFailure(`${opts.node.host}:${opts.node.port}`);
4823
+ this.#recordNodeFailure(nodeId);
4824
+ return undefined;
4825
+ };
4826
+ if (pinnedRoute) {
4827
+ // The pinned route is the only one whose source the node's ping_id can
4828
+ // match — retry it rather than wandering to other paths.
4829
+ const pinnedAttempts = Math.max(1, opts.attempts ?? 3);
4830
+ for (let attempt = 0; attempt < pinnedAttempts; attempt++) {
4831
+ const response = await exchange(pinnedRoute);
4832
+ if (response)
4833
+ return response;
4834
+ }
4835
+ return undefined;
4445
4836
  }
4446
- if (opts.allowDirectFallback) {
4837
+ const attempts = opts.attempts ?? 3;
4838
+ for (let attempt = 0; attempt < attempts; attempt++) {
4447
4839
  const waiter = this.#waitForAnnounceResponse(opts.node, opts.sendBack, {
4448
4840
  requesterSecretKey: opts.senderSecretKey,
4449
4841
  nodePublicKey: opts.nodePublicKey
4450
4842
  });
4451
- await this.#sendPacket(request, opts.node);
4843
+ const routeUsed = await this.#sendThroughOnionPath(request, opts.node, attempt);
4452
4844
  const response = await waiter;
4453
4845
  if (response) {
4454
- this.#recordNodeSuccess(`${opts.node.host}:${opts.node.port}`);
4846
+ this.#recordNodeSuccess(nodeId);
4847
+ this.#announceRouteUsed.set(nodeId, routeUsed);
4455
4848
  return response;
4456
4849
  }
4457
- this.#recordNodeFailure(`${opts.node.host}:${opts.node.port}`);
4850
+ this.#recordNodeFailure(nodeId);
4851
+ }
4852
+ if (opts.allowDirectFallback) {
4853
+ const response = await exchange("direct");
4854
+ if (response)
4855
+ return response;
4458
4856
  }
4459
4857
  return undefined;
4460
4858
  }
@@ -4513,12 +4911,12 @@ export class Peer {
4513
4911
  const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
4514
4912
  await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
4515
4913
  }
4516
- async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0) {
4517
- const path = this.#selectOnionPath(nodeD, pathOffset);
4914
+ async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0, forcedPath) {
4915
+ const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
4518
4916
  if (!path) {
4519
4917
  this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
4520
4918
  await this.#sendPacket(payloadForNodeD, nodeD);
4521
- return;
4919
+ return "direct";
4522
4920
  }
4523
4921
  this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
4524
4922
  const packet = createOnionRequest0({
@@ -4534,6 +4932,7 @@ export class Peer {
4534
4932
  payloadForNodeD
4535
4933
  });
4536
4934
  await this.#sendPacket(packet, path.nodeA.node);
4935
+ return path;
4537
4936
  }
4538
4937
  #selectOnionPath(nodeD, pathOffset = 0) {
4539
4938
  const nodeDId = `${nodeD.host}:${nodeD.port}`;
@@ -4754,6 +5153,14 @@ export class Peer {
4754
5153
  remotePort: undefined,
4755
5154
  status: record.status === "online" ? "offline" : record.status
4756
5155
  };
5156
+ // Scrub a remoteHost on one of our own VIRTUAL/TUN interfaces (older
5157
+ // builds could cache the self-poisoned TUN source) — sends there
5158
+ // loop through the overlay and die. Self PHYSICAL addresses are kept:
5159
+ // a same-host peer (iOS simulator) legitimately lives there.
5160
+ if (trusted.remoteHost && isOwnVirtualAddress(trusted.remoteHost)) {
5161
+ trusted.remoteHost = undefined;
5162
+ trusted.remotePort = undefined;
5163
+ }
4757
5164
  this.#friends.set(record.pubkey, trusted);
4758
5165
  }
4759
5166
  this.#debugLog(`loaded ${this.#friends.size} persisted friends`);
@@ -5116,6 +5523,8 @@ function isCgnatAddress(host) {
5116
5523
  let _lanIfaceCacheMs = 0;
5117
5524
  let _lanAddrsCache = [];
5118
5525
  let _lanSubnetsCache = [];
5526
+ let _allOwnAddrsCache = new Set();
5527
+ let _ownVirtualAddrsCache = new Set();
5119
5528
  function refreshLanIfaceCache() {
5120
5529
  const now = Date.now();
5121
5530
  if (_lanIfaceCacheMs !== 0 && now - _lanIfaceCacheMs < 5000)
@@ -5123,12 +5532,22 @@ function refreshLanIfaceCache() {
5123
5532
  _lanIfaceCacheMs = now;
5124
5533
  const addrs = [];
5125
5534
  const subnets = [];
5535
+ const allOwn = new Set();
5536
+ const ownVirtual = new Set();
5126
5537
  try {
5127
5538
  for (const [name, list] of Object.entries(networkInterfaces())) {
5128
- if (!list || VIRTUAL_IFACE_RE.test(name))
5539
+ if (!list)
5129
5540
  continue;
5541
+ const virtual = VIRTUAL_IFACE_RE.test(name);
5130
5542
  for (const info of list) {
5131
- if (info.family !== "IPv4" || info.internal || isCgnatAddress(info.address))
5543
+ if (info.family !== "IPv4")
5544
+ continue;
5545
+ // ALL own addresses — including virtual/TUN interfaces — feed the
5546
+ // self-source checks (see isOwnAddress/isOwnVirtualAddress).
5547
+ allOwn.add(info.address);
5548
+ if (virtual)
5549
+ ownVirtual.add(info.address);
5550
+ if (virtual || info.internal || isCgnatAddress(info.address))
5132
5551
  continue;
5133
5552
  addrs.push(info.address);
5134
5553
  const addr = ipv4ToInt(info.address);
@@ -5144,6 +5563,23 @@ function refreshLanIfaceCache() {
5144
5563
  }
5145
5564
  _lanAddrsCache = addrs;
5146
5565
  _lanSubnetsCache = subnets;
5566
+ _allOwnAddrsCache = allOwn;
5567
+ _ownVirtualAddrsCache = ownVirtual;
5568
+ }
5569
+ /** True when host is one of THIS machine's own IPv4 addresses (any
5570
+ * interface, physical or virtual/TUN). Cached (see refreshLanIfaceCache). */
5571
+ function isOwnAddress(host) {
5572
+ refreshLanIfaceCache();
5573
+ return _allOwnAddrsCache.has(host);
5574
+ }
5575
+ /** True when host belongs to one of our own VIRTUAL/TUN interfaces (e.g.
5576
+ * agentnet0's 10.86.x.y). Packets sent there re-enter the overlay router
5577
+ * and loop — never a usable peer endpoint. A self PHYSICAL address with a
5578
+ * DIFFERENT port is fine: that's a same-host peer (e.g. the iOS simulator
5579
+ * on this Mac), and local delivery works. */
5580
+ function isOwnVirtualAddress(host) {
5581
+ refreshLanIfaceCache();
5582
+ return _ownVirtualAddrsCache.has(host);
5147
5583
  }
5148
5584
  function getPhysicalLanAddresses() {
5149
5585
  refreshLanIfaceCache();
@@ -14,4 +14,9 @@ export type FriendRecord = {
14
14
  * has been pending. */
15
15
  requestedAt?: number;
16
16
  acceptedAt?: number;
17
+ /** Base58 DHT pubkey the friend last announced (native peers handshake
18
+ * relays with this key, NOT their identity key). Persisted so a restart
19
+ * can immediately park relay ROUTING_REQUESTs under the right key
20
+ * instead of waiting for the friend's next DHT-PK announce. */
21
+ dhtPubkey?: string;
17
22
  };
@@ -11,6 +11,13 @@ export type NetworkNode = {
11
11
  * relays this way in DHT-PK extras.
12
12
  */
13
13
  isTcp?: boolean;
14
+ /** Express relays only: transport scheme. Defaults to HTTPS. Set `false` to
15
+ * reach an express node over plain HTTP — the express payload is already
16
+ * end-to-end encrypted (nacl.secretbox), so HTTP keeps CONTENT private from
17
+ * on-path observers; it does expose the userIDs in the request path. Lets a
18
+ * relay run on a raw IP with no TLS cert / domain / ICP filing, exactly like
19
+ * a bootstrap node. */
20
+ tls?: boolean;
14
21
  /** Decoded 32-byte form of `pk`, cached lazily on first use so the hot
15
22
  * DHT-distance sort (#closestKnownNodes, run on every get_nodes we answer)
16
23
  * doesn't base58-decode the whole table on every call. */
@@ -87,6 +94,10 @@ export type FriendInfoEvent = {
87
94
  export type TextMessage = {
88
95
  pubkey: string;
89
96
  text: string;
97
+ /** Delivery path: "online" = live net_crypto session (direct/relay), "offline"
98
+ * = express store-and-forward. Lets the UI color the two differently so a user
99
+ * can see when online delivery is failing and only offline messages land. */
100
+ via?: "online" | "offline";
90
101
  };
91
102
  /**
92
103
  * An application-defined custom packet received from a friend, over the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -68,7 +68,8 @@
68
68
  "demo:tox-dht-crypto": "pnpm run build && node scripts/tox-dht-crypto-demo.mjs",
69
69
  "test:compat": "pnpm run build && node scripts/compat-selftest.mjs",
70
70
  "smoke:join": "node scripts/smoke-join.mjs",
71
- "typecheck": "tsc -p tsconfig.json --noEmit"
71
+ "typecheck": "tsc -p tsconfig.json --noEmit",
72
+ "test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs"
72
73
  },
73
74
  "dependencies": {
74
75
  "flatbuffers": "^25.9.23",