@decentnetwork/peer 0.1.152 → 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.js CHANGED
@@ -6960,7 +6960,41 @@ export class Peer {
6960
6960
  const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
6961
6961
  await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
6962
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
+ }
6963
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;
6964
6998
  const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
6965
6999
  if (!path) {
6966
7000
  // No 3-hop UDP path. "Direct" then meant a plain UDP send — which on a
@@ -6971,37 +7005,29 @@ export class Peer {
6971
7005
  // A RELAYED onion request needs only two hops: the relay is node A. So
6972
7006
  // try that before giving up, and only fall through to the UDP send when
6973
7007
  // there are no relays or not even two usable hops.
6974
- if (this.#tcpRelays && this.#tcpRelays.connectedCount() > 0) {
6975
- const hops = this.#selectTcpOnionHops(nodeD, pathOffset);
6976
- if (hops) {
6977
- try {
6978
- const tcpPacket = createOnionRequest0Tcp({
6979
- nodeBHost: hops.nodeB.host,
6980
- nodeBPort: hops.nodeB.port,
6981
- nodeBPublicKey: hops.nodeB.publicKey,
6982
- nodeCHost: hops.nodeC.host,
6983
- nodeCPort: hops.nodeC.port,
6984
- nodeCPublicKey: hops.nodeC.publicKey,
6985
- nodeDHost: nodeD.host,
6986
- nodeDPort: nodeD.port,
6987
- payloadForNodeD
6988
- });
6989
- const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
6990
- if (sent > 0) {
6991
- this.#diagTcpOnionSent += 1;
6992
- 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})`);
6993
- return "direct";
6994
- }
6995
- }
6996
- catch {
6997
- /* malformed hop key — fall through to the UDP attempt */
6998
- }
6999
- }
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";
7000
7020
  }
7001
7021
  this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
7002
7022
  await this.#sendPacket(payloadForNodeD, nodeD);
7003
7023
  return "direct";
7004
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
+ }
7005
7031
  this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
7006
7032
  const packet = createOnionRequest0({
7007
7033
  nodeAPublicKey: path.nodeA.publicKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.152",
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",