@decentnetwork/peer 0.1.151 → 0.1.154

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.
@@ -2,6 +2,7 @@ import { ByteBuffer, Encoding } from "flatbuffers";
2
2
  import nacl from "tweetnacl";
3
3
  import { base58ToBytes, bytesToBase58 } from "../utils/base58.js";
4
4
  import { randomBytes } from "../utils/bytes.js";
5
+ import { sharedKey as sharedKeyFor } from "../crypto/shared-key.js";
5
6
  const EXPRESS_MAGIC = 0xca6ee595;
6
7
  const NONCE_SIZE = 24;
7
8
  const HTTP_TIMEOUT_MS = 15000;
@@ -34,7 +35,7 @@ export class LegacyExpressClient {
34
35
  host: node.host,
35
36
  port: node.port,
36
37
  tls: node.tls !== false, // default HTTPS; opt into HTTP with tls:false
37
- sharedKey: nacl.box.before(expressPk, this.#selfKeyPair.secretKey)
38
+ sharedKey: sharedKeyFor(expressPk, this.#selfKeyPair.secretKey)
38
39
  };
39
40
  });
40
41
  }
@@ -49,7 +50,7 @@ export class LegacyExpressClient {
49
50
  if (friendPk.length !== 32) {
50
51
  throw new Error("friend user id must decode to 32-byte public key");
51
52
  }
52
- const friendSharedKey = nacl.box.before(friendPk, this.#selfKeyPair.secretKey);
53
+ const friendSharedKey = sharedKeyFor(friendPk, this.#selfKeyPair.secretKey);
53
54
  const friendEncrypted = encrypt(friendSharedKey, carrierPacket);
54
55
  await this.#postEncrypted(friendUserId, friendEncrypted);
55
56
  }
@@ -112,7 +113,7 @@ export class LegacyExpressClient {
112
113
  if (friendPk.length !== 32) {
113
114
  continue;
114
115
  }
115
- const friendSharedKey = nacl.box.before(friendPk, this.#selfKeyPair.secretKey);
116
+ const friendSharedKey = sharedKeyFor(friendPk, this.#selfKeyPair.secretKey);
116
117
  const packet = decrypt(friendSharedKey, msg.payload);
117
118
  if (!packet) {
118
119
  continue;
@@ -1,6 +1,7 @@
1
1
  import nacl from "tweetnacl";
2
2
  import { createHash } from "node:crypto";
3
3
  import { concatBytes, randomBytes } from "../utils/bytes.js";
4
+ import { sharedKey as sharedKeyFor } from "../crypto/shared-key.js";
4
5
  export const NET_PACKET_COOKIE_REQUEST = 0x18;
5
6
  export const NET_PACKET_COOKIE_RESPONSE = 0x19;
6
7
  export const NET_PACKET_CRYPTO_HS = 0x1a;
@@ -44,7 +45,7 @@ export function createCookieRequest(opts) {
44
45
  throw new Error("cookie request plaintext size mismatch");
45
46
  }
46
47
  const nonce = randomBytes(NONCE_SIZE);
47
- const sharedKey = nacl.box.before(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
48
+ const sharedKey = sharedKeyFor(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
48
49
  const cipher = nacl.secretbox(plain, nonce, sharedKey);
49
50
  return concatBytes([
50
51
  Uint8Array.of(NET_PACKET_COOKIE_REQUEST),
@@ -60,7 +61,7 @@ export function openCookieRequest(packet, opts) {
60
61
  const senderDhtPublicKey = packet.slice(1, 1 + KEY_SIZE);
61
62
  const nonce = packet.slice(1 + KEY_SIZE, 1 + KEY_SIZE + NONCE_SIZE);
62
63
  const cipher = packet.slice(1 + KEY_SIZE + NONCE_SIZE);
63
- const sharedKey = nacl.box.before(senderDhtPublicKey, opts.receiverDhtSecretKey);
64
+ const sharedKey = sharedKeyFor(senderDhtPublicKey, opts.receiverDhtSecretKey);
64
65
  const plain = nacl.secretbox.open(cipher, nonce, sharedKey);
65
66
  if (!plain || plain.length !== COOKIE_REQUEST_PLAIN_LENGTH) {
66
67
  return undefined;
@@ -79,7 +80,7 @@ export function createCookieResponse(opts) {
79
80
  });
80
81
  const plain = concatBytes([cookie, writeUint64Le(opts.request.echo)]);
81
82
  const nonce = randomBytes(NONCE_SIZE);
82
- const sharedKey = nacl.box.before(opts.request.senderDhtPublicKey, opts.receiverDhtSecretKey);
83
+ const sharedKey = sharedKeyFor(opts.request.senderDhtPublicKey, opts.receiverDhtSecretKey);
83
84
  const cipher = nacl.secretbox(plain, nonce, sharedKey);
84
85
  return concatBytes([Uint8Array.of(NET_PACKET_COOKIE_RESPONSE), nonce, cipher]);
85
86
  }
@@ -89,7 +90,7 @@ export function openCookieResponse(packet, opts) {
89
90
  }
90
91
  const nonce = packet.slice(1, 1 + NONCE_SIZE);
91
92
  const cipher = packet.slice(1 + NONCE_SIZE);
92
- const sharedKey = nacl.box.before(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
93
+ const sharedKey = sharedKeyFor(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
93
94
  const plain = nacl.secretbox.open(cipher, nonce, sharedKey);
94
95
  if (!plain || plain.length !== COOKIE_RESPONSE_PLAIN_LENGTH) {
95
96
  return undefined;
@@ -135,7 +136,7 @@ export function createCryptoHandshake(opts) {
135
136
  sha512(opts.recipientCookie),
136
137
  ownCookie
137
138
  ]);
138
- const sharedKey = nacl.box.before(opts.receiverRealPublicKey, opts.senderRealSecretKey);
139
+ const sharedKey = sharedKeyFor(opts.receiverRealPublicKey, opts.senderRealSecretKey);
139
140
  const innerCipher = nacl.secretbox(innerPlain, innerNonce, sharedKey);
140
141
  return concatBytes([Uint8Array.of(NET_PACKET_CRYPTO_HS), opts.recipientCookie, innerNonce, innerCipher]);
141
142
  }
@@ -152,7 +153,7 @@ export function openCryptoHandshake(packet, opts) {
152
153
  const senderDhtPublicKey = cookieParsed.dhtPublicKey;
153
154
  const nonce = packet.slice(1 + COOKIE_LENGTH, 1 + COOKIE_LENGTH + NONCE_SIZE);
154
155
  const cipher = packet.slice(1 + COOKIE_LENGTH + NONCE_SIZE);
155
- const sharedKey = nacl.box.before(senderRealPublicKey, opts.receiverRealSecretKey);
156
+ const sharedKey = sharedKeyFor(senderRealPublicKey, opts.receiverRealSecretKey);
156
157
  const inner = nacl.secretbox.open(cipher, nonce, sharedKey);
157
158
  if (!inner || inner.length !== HANDSHAKE_INNER_LENGTH) {
158
159
  return undefined;
@@ -0,0 +1,4 @@
1
+ export declare function sharedKey(theirPublicKey: Uint8Array, ourSecretKey: Uint8Array): Uint8Array;
2
+ /** Drop everything — for tests, and for a key rotation that reuses the same
3
+ * Uint8Array instance. */
4
+ export declare function clearSharedKeyCache(): void;
@@ -0,0 +1,45 @@
1
+ import nacl from "tweetnacl";
2
+ import { bytesToHex } from "../utils/bytes.js";
3
+ /**
4
+ * X25519 shared-secret derivation, memoised.
5
+ *
6
+ * `nacl.box.before(theirPk, ourSk)` is a scalar multiplication — the single
7
+ * most expensive thing tweetnacl does, and it is pure JS. Every packet that
8
+ * derived its key inline paid for one.
9
+ *
10
+ * That cost is invisible until a node has friends. Measured with a 53-friend
11
+ * list against a peer that could reach none of them (the ordinary case: a
12
+ * laptop whose friends are asleep), the connection loop's cookie requests put
13
+ *
14
+ * 60.8% tweetnacl M (bignum multiply)
15
+ * 11.4% crypto_scalarmult
16
+ *
17
+ * of the process on one function — 72% of the CPU, for keys that never change.
18
+ * A browser tab showed the same shape at 100% of a core for 25 hours.
19
+ *
20
+ * The result depends only on (theirPk, ourSk), so it is cached. Keyed on the
21
+ * public key, with the secret compared by IDENTITY rather than content: a peer
22
+ * passes the same Uint8Array every time, and a different keypair recomputes
23
+ * rather than risking a stale hit. Nothing secret is used as a map key.
24
+ */
25
+ const cache = new Map();
26
+ const MAX_ENTRIES = 1024;
27
+ export function sharedKey(theirPublicKey, ourSecretKey) {
28
+ const id = bytesToHex(theirPublicKey);
29
+ const hit = cache.get(id);
30
+ if (hit && hit.sk === ourSecretKey)
31
+ return hit.key;
32
+ const key = nacl.box.before(theirPublicKey, ourSecretKey);
33
+ if (cache.size >= MAX_ENTRIES) {
34
+ const oldest = cache.keys().next().value;
35
+ if (oldest !== undefined)
36
+ cache.delete(oldest);
37
+ }
38
+ cache.set(id, { sk: ourSecretKey, key });
39
+ return key;
40
+ }
41
+ /** Drop everything — for tests, and for a key rotation that reuses the same
42
+ * Uint8Array instance. */
43
+ export function clearSharedKeyCache() {
44
+ cache.clear();
45
+ }
package/dist/peer.d.ts CHANGED
@@ -80,13 +80,31 @@ export declare class Peer {
80
80
  };
81
81
  addKnownNodes(nodes: NetworkNode[]): void;
82
82
  knownNodes(): NetworkNode[];
83
- sendFriendRequest(pubkey: string, hello?: string): Promise<void>;
83
+ /** @param opts.force Send even when this friend looks already-accepted.
84
+ *
85
+ * The idempotency guard below is right for automatic retries and wrong for
86
+ * a person clicking "add". If the other side REJECTED an earlier request,
87
+ * their app forgets it — but a net_crypto session may already have been
88
+ * established, so this side keeps acceptedAt and skips every later attempt.
89
+ * Neither peer is then in the other's friend list and nothing either user
90
+ * does can fix it: every add is silently dropped here. A deliberate add has
91
+ * to be able to say "I mean it". */
92
+ sendFriendRequest(pubkey: string, hello?: string, opts?: {
93
+ force?: boolean;
94
+ }): Promise<void>;
84
95
  lastFriendRequestDispatch(): {
85
96
  transport: "onion" | "direct";
86
97
  routes: number;
87
98
  targets: number;
88
99
  } | undefined;
89
100
  acceptFriendRequest(pubkey: string): Promise<void>;
101
+ /** Decline an inbound request.
102
+ *
103
+ * Also drops any friend record and session the request already created.
104
+ * Clearing only the pending list left the transport half-attached: the
105
+ * sender saw a session, marked us accepted, and its idempotency guard then
106
+ * skipped every later attempt — so a rejected peer could never ask again,
107
+ * and neither side could see why. */
90
108
  rejectFriendRequest(pubkey: string): void;
91
109
  /**
92
110
  * Drop a friend entirely: tear down any active net_crypto session, forget
package/dist/peer.js CHANGED
@@ -1040,7 +1040,16 @@ export class Peer {
1040
1040
  knownNodes() {
1041
1041
  return [...this.#knownNodes];
1042
1042
  }
1043
- async sendFriendRequest(pubkey, hello) {
1043
+ /** @param opts.force Send even when this friend looks already-accepted.
1044
+ *
1045
+ * The idempotency guard below is right for automatic retries and wrong for
1046
+ * a person clicking "add". If the other side REJECTED an earlier request,
1047
+ * their app forgets it — but a net_crypto session may already have been
1048
+ * established, so this side keeps acceptedAt and skips every later attempt.
1049
+ * Neither peer is then in the other's friend list and nothing either user
1050
+ * does can fix it: every add is silently dropped here. A deliberate add has
1051
+ * to be able to say "I mean it". */
1052
+ async sendFriendRequest(pubkey, hello, opts) {
1044
1053
  if (!this.#keyPair || !this.#announceDataKey) {
1045
1054
  throw new Error("Peer is not started");
1046
1055
  }
@@ -1056,7 +1065,7 @@ export class Peer {
1056
1065
  const friendAddress = parseCarrierAddress(pubkey);
1057
1066
  const friendId = carrierIdFromPublicKey(friendAddress.publicKey);
1058
1067
  const existing = this.#friends.get(friendId);
1059
- if (existing?.acceptedAt) {
1068
+ if (existing?.acceptedAt && !opts?.force) {
1060
1069
  this.#debugLog(`friend request skipped — ${friendId} already accepted at ${new Date(existing.acceptedAt).toISOString()}`);
1061
1070
  this.#lastFriendRequestDispatch = {
1062
1071
  transport: "onion",
@@ -1237,8 +1246,17 @@ export class Peer {
1237
1246
  this.#debugLog(`accept friend: initiate session failed for ${pubkey}: ${error.message}`);
1238
1247
  });
1239
1248
  }
1249
+ /** Decline an inbound request.
1250
+ *
1251
+ * Also drops any friend record and session the request already created.
1252
+ * Clearing only the pending list left the transport half-attached: the
1253
+ * sender saw a session, marked us accepted, and its idempotency guard then
1254
+ * skipped every later attempt — so a rejected peer could never ask again,
1255
+ * and neither side could see why. */
1240
1256
  rejectFriendRequest(pubkey) {
1241
1257
  this.#pendingFriendRequests.delete(pubkey);
1258
+ if (this.#friends.has(pubkey))
1259
+ this.removeFriend(pubkey);
1242
1260
  }
1243
1261
  /**
1244
1262
  * Drop a friend entirely: tear down any active net_crypto session, forget
@@ -6942,7 +6960,41 @@ export class Peer {
6942
6960
  const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
6943
6961
  await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
6944
6962
  }
6963
+ /** Hand an onion request to the connected relays, which stand in for hop A. */
6964
+ #sendOnionOverRelays(nodeB, nodeC, nodeD, payloadForNodeD) {
6965
+ if (!this.#tcpRelays || this.#tcpRelays.connectedCount() === 0)
6966
+ return;
6967
+ try {
6968
+ const tcpPacket = createOnionRequest0Tcp({
6969
+ nodeBHost: nodeB.host,
6970
+ nodeBPort: nodeB.port,
6971
+ nodeBPublicKey: nodeB.publicKey,
6972
+ nodeCHost: nodeC.host,
6973
+ nodeCPort: nodeC.port,
6974
+ nodeCPublicKey: nodeC.publicKey,
6975
+ nodeDHost: nodeD.host,
6976
+ nodeDPort: nodeD.port,
6977
+ payloadForNodeD
6978
+ });
6979
+ const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
6980
+ if (sent > 0) {
6981
+ this.#diagTcpOnionSent += 1;
6982
+ this.#debugVerboseLog(`tcp onion request sent via ${sent} relay(s) to ${nodeD.host}:${nodeD.port}`);
6983
+ }
6984
+ }
6985
+ catch {
6986
+ /* malformed hop key — nothing to send */
6987
+ }
6988
+ }
6945
6989
  async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0, forcedPath) {
6990
+ // Does this host have real UDP? The browser shim binds and drops, and its
6991
+ // socket reports port 0. Building the UDP onion request there is six
6992
+ // scalar multiplications — three ephemeral keypairs and three
6993
+ // Diffie-Hellmans, in pure JS — for a packet that goes nowhere. Measured:
6994
+ // onion construction was 79% of all crypto time, split almost exactly
6995
+ // between the UDP variant and its TCP twin, and a browser tab held a core
6996
+ // at 100% for 25 hours doing it. Decide BEFORE constructing anything.
6997
+ const udpUsable = (this.#udp?.localPort() ?? 0) > 0;
6946
6998
  const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
6947
6999
  if (!path) {
6948
7000
  // No 3-hop UDP path. "Direct" then meant a plain UDP send — which on a
@@ -6953,37 +7005,29 @@ export class Peer {
6953
7005
  // A RELAYED onion request needs only two hops: the relay is node A. So
6954
7006
  // try that before giving up, and only fall through to the UDP send when
6955
7007
  // there are no relays or not even two usable hops.
6956
- if (this.#tcpRelays && this.#tcpRelays.connectedCount() > 0) {
6957
- const hops = this.#selectTcpOnionHops(nodeD, pathOffset);
6958
- if (hops) {
6959
- try {
6960
- const tcpPacket = createOnionRequest0Tcp({
6961
- nodeBHost: hops.nodeB.host,
6962
- nodeBPort: hops.nodeB.port,
6963
- nodeBPublicKey: hops.nodeB.publicKey,
6964
- nodeCHost: hops.nodeC.host,
6965
- nodeCPort: hops.nodeC.port,
6966
- nodeCPublicKey: hops.nodeC.publicKey,
6967
- nodeDHost: nodeD.host,
6968
- nodeDPort: nodeD.port,
6969
- payloadForNodeD
6970
- });
6971
- const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
6972
- if (sent > 0) {
6973
- this.#diagTcpOnionSent += 1;
6974
- this.#debugLog(`no 3-hop path for ${nodeD.host}:${nodeD.port} — relayed 2-hop onion via ${sent} relay(s) (B=${hops.nodeB.host} C=${hops.nodeC.host})`);
6975
- return "direct";
6976
- }
6977
- }
6978
- catch {
6979
- /* malformed hop key — fall through to the UDP attempt */
6980
- }
6981
- }
7008
+ const hops = this.#tcpRelays && this.#tcpRelays.connectedCount() > 0
7009
+ ? this.#selectTcpOnionHops(nodeD, pathOffset)
7010
+ : undefined;
7011
+ if (hops) {
7012
+ this.#sendOnionOverRelays(hops.nodeB, hops.nodeC, nodeD, payloadForNodeD);
7013
+ return "direct";
7014
+ }
7015
+ if (!udpUsable) {
7016
+ // Nothing can carry it: no relay hops and no UDP. Say so instead of
7017
+ // handing the packet to a socket that drops it silently.
7018
+ this.#debugLog(`no route for ${nodeD.host}:${nodeD.port} — no relay hops and no UDP`);
7019
+ return "direct";
6982
7020
  }
6983
7021
  this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
6984
7022
  await this.#sendPacket(payloadForNodeD, nodeD);
6985
7023
  return "direct";
6986
7024
  }
7025
+ if (!udpUsable) {
7026
+ // No UDP: go straight to the relay form, which is the only one that can
7027
+ // actually leave this host.
7028
+ this.#sendOnionOverRelays(path.nodeB, path.nodeC, nodeD, payloadForNodeD);
7029
+ return path;
7030
+ }
6987
7031
  this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
6988
7032
  const packet = createOnionRequest0({
6989
7033
  nodeAPublicKey: path.nodeA.publicKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.151",
3
+ "version": "0.1.154",
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",