@hive-p2p/browser 1.0.68 → 1.0.70

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/core/arbiter.mjs CHANGED
@@ -99,7 +99,7 @@ export class Arbiter {
99
99
  /** Call from HiveP2P module only! @param {string} from @param {any} message @param {Uint8Array} serialized @param {number} [powCheckFactor] default: 0.01 (1%) */
100
100
  async digestMessage(from, message, serialized, powCheckFactor = .01) {
101
101
  const { senderId, pubkey, topic, expectedEnd } = message; // avoid powControl() on banished peers
102
- if (!await this.#signatureControl(from, message, serialized)) return;
102
+ if (!this.#signatureControl(from, message, serialized)) return;
103
103
  if (!this.#lengthControl(from, topic ? 'gossip' : 'unicast', serialized, expectedEnd)) return;
104
104
 
105
105
  const routeOrHopsOk = topic ? this.#hopsControl(from, message) : this.#routeLengthControl(from, message);
@@ -111,11 +111,11 @@ export class Arbiter {
111
111
  return true;
112
112
  }
113
113
  /** @param {string} from @param {import('./gossip.mjs').GossipMessage} message @param {Uint8Array} serialized */
114
- async #signatureControl(from, message, serialized) {
114
+ #signatureControl(from, message, serialized) {
115
115
  try {
116
116
  const { pubkey, signature, signatureStart } = message;
117
117
  const signedData = serialized.subarray(0, signatureStart);
118
- const signatureValid = await this.cryptoCodex.verifySignature(pubkey, signedData, signature);
118
+ const signatureValid = this.cryptoCodex.verifySignature(pubkey, signedData, signature);
119
119
  if (!signatureValid) throw new Error('Gossip signature invalid');
120
120
  this.adjustTrust(from, TRUST_VALUES.VALID_SIGNATURE, 'Gossip signature valid');
121
121
  return true;
package/core/config.mjs CHANGED
@@ -158,6 +158,10 @@ export const UNICAST = { // MARKERS RANGE: 0-127
158
158
  '2': 'signal_answer',
159
159
  signal_offer: 3,
160
160
  '3': 'signal_offer',
161
+ privacy: 4,
162
+ '4': 'privacy',
163
+ private_message: 5,
164
+ '5': 'private_message',
161
165
  },
162
166
  }
163
167
 
@@ -3,56 +3,13 @@ import { SIMULATION, NODE, IDENTITY, GOSSIP, UNICAST, LOG_CSS } from './config.m
3
3
  import { GossipMessage } from './gossip.mjs';
4
4
  import { DirectMessage, ReroutedDirectMessage } from './unicast.mjs';
5
5
  import { Converter } from '../services/converter.mjs';
6
- import { ed25519, Argon2Unified } from '../services/cryptos.mjs'; // now exposed in full and browser builds
7
-
8
- class Ed25519BatchVerifier {
9
- #verifyQueue = [];
10
- #verifyResolvers = new Map();
11
- #batchTimer = null;
12
- #nextId = 0;
13
- #BATCH_SIZE = 10;
14
- #BATCH_TIMEOUT = 5;
15
-
16
- verifySignature(publicKey, dataToVerify, signature) {
17
- return new Promise((resolve) => {
18
- const id = this.#nextId++;
19
- this.#verifyQueue.push({ id, publicKey, dataToVerify, signature });
20
- this.#verifyResolvers.set(id, resolve);
21
-
22
- // Flush immediately if batch full
23
- if (this.#verifyQueue.length >= this.#BATCH_SIZE) {
24
- this.#flushBatch();
25
- return;
26
- }
27
-
28
- // Otherwise schedule flush
29
- if (!this.#batchTimer)
30
- this.#batchTimer = setTimeout(() => this.#flushBatch(), this.#BATCH_TIMEOUT);
31
- });
32
- }
33
-
34
- async #flushBatch() {
35
- if (this.#batchTimer) clearTimeout(this.#batchTimer), this.#batchTimer = null;
36
- if (this.#verifyQueue.length === 0) return;
37
-
38
- const batch = this.#verifyQueue.splice(0);
39
- const results = await Promise.all(
40
- batch.map(item => ed25519.verifyAsync(item.signature, item.dataToVerify, item.publicKey))
41
- );
42
-
43
- for (let i = 0; i < batch.length; i++) {
44
- this.#verifyResolvers.get(batch[i].id)(results[i]);
45
- this.#verifyResolvers.delete(batch[i].id);
46
- }
47
- }
48
- }
6
+ import { ed25519, x25519, chacha20poly1305, randomBytes , Argon2Unified } from '../services/cryptos.mjs'; // now exposed in full and browser builds
7
+ import { concatBytes } from '@noble/ciphers/utils.js';
49
8
 
50
9
  export class CryptoCodex {
51
10
  argon2 = new Argon2Unified();
52
11
  converter = new Converter();
53
12
  AVOID_CRYPTO = false;
54
- /** @type {Ed25519BatchVerifier} only if "AVOID_CRYPTO" is disabled*/
55
- verifier;
56
13
  verbose = NODE.DEFAULT_VERBOSE;
57
14
  /** @type {string} */ id;
58
15
  /** @type {Uint8Array} */ publicKey;
@@ -88,7 +45,6 @@ export class CryptoCodex {
88
45
  if (this.nodeId) return;
89
46
  await this.#generateAntiSybilIdentity(seed, asPublicNode);
90
47
  this.AVOID_CRYPTO = false; // enable crypto operations
91
- this.verifier = new Ed25519BatchVerifier();
92
48
  if (!this.id) throw new Error('Failed to generate identity');
93
49
  }
94
50
  /** Check if the pubKey meets the difficulty using Argon2 derivation @param {Uint8Array} publicKey */
@@ -97,6 +53,7 @@ export class CryptoCodex {
97
53
  const { bitsString } = await this.argon2.hash(publicKey, 'HiveP2P', IDENTITY.ARGON2_MEM) || {};
98
54
  if (bitsString && bitsString.startsWith('0'.repeat(IDENTITY.DIFFICULTY))) return true;
99
55
  }
56
+ /** @param {Uint8Array} publicKey */
100
57
  #idFromPublicKey(publicKey) {
101
58
  if (IDENTITY.ARE_IDS_HEX) return this.converter.bytesToHex(publicKey.slice(0, this.idLength), IDENTITY.ID_LENGTH);
102
59
  return this.converter.bytesToString(publicKey.slice(0, IDENTITY.ID_LENGTH));
@@ -105,7 +62,7 @@ export class CryptoCodex {
105
62
  async #generateAntiSybilIdentity(seed, asPublicNode) {
106
63
  const maxIterations = (2 ** IDENTITY.DIFFICULTY) * 100; // avoid infinite loop
107
64
  for (let i = 0; i < maxIterations; i++) { // avoid infinite loop
108
- const { secretKey, publicKey } = await ed25519.keygenAsync(seed);
65
+ const { secretKey, publicKey } = ed25519.keygen(seed);
109
66
  const id = this.#idFromPublicKey(publicKey);
110
67
  if (asPublicNode && !this.isPublicNode(id)) continue; // Check prefix
111
68
  if (!asPublicNode && this.isPublicNode(id)) continue; // Check prefix
@@ -119,16 +76,41 @@ export class CryptoCodex {
119
76
  if (this.verbose > 0) console.log(`%cFAILED to generate id after ${maxIterations} iterations. Try lowering the difficulty.`, LOG_CSS.CRYPTO_CODEX);
120
77
  }
121
78
 
122
- // MESSSAGE CREATION (SERIALIZATION AND SIGNATURE INCLUDED)
79
+ // PRIVACY
80
+ generateEphemeralX25519Keypair() {
81
+ const { secretKey, publicKey } = x25519.keygen();
82
+ return { myPub: publicKey, myPriv: secretKey };
83
+ }
84
+ computeX25519SharedSecret(secret, pub) {
85
+ return x25519.getSharedSecret(secret, pub);
86
+ }
87
+ /** @param {Uint8Array} data @param {Uint8Array} sharedSecret */
88
+ encryptData(data, sharedSecret) {
89
+ if (!sharedSecret) throw new Error('Cannot encrypt data without shared secret.');
90
+ const nonce = randomBytes(12);
91
+ const cipher = chacha20poly1305(sharedSecret, nonce);
92
+ const encrypted = cipher.encrypt(data);
93
+ return concatBytes(nonce, encrypted);
94
+ }
95
+ /** @param {Uint8Array} encryptedData @param {Uint8Array} sharedSecret */
96
+ decryptData(encryptedData, sharedSecret) {
97
+ if (!sharedSecret) throw new Error('Cannot decrypt data without shared secret.');
98
+ const nonce = encryptedData.slice(0, 12);
99
+ const cipher = chacha20poly1305(sharedSecret, nonce);
100
+ const decrypted = cipher.decrypt(encryptedData.slice(12));
101
+ return decrypted;
102
+ }
103
+
104
+ // MESSAGE CREATION (SERIALIZATION AND SIGNATURE INCLUDED)
123
105
  /** @param {Uint8Array} bufferView @param {Uint8Array} privateKey @param {number} [signaturePosition] */
124
- async signBufferViewAndAppendSignature(bufferView, privateKey, signaturePosition = bufferView.length - IDENTITY.SIGNATURE_LENGTH) {
106
+ signBufferViewAndAppendSignature(bufferView, privateKey, signaturePosition = bufferView.length - IDENTITY.SIGNATURE_LENGTH) {
125
107
  if (this.AVOID_CRYPTO) return;
126
108
  const dataToSign = bufferView.subarray(0, signaturePosition);
127
- const signature = await ed25519.signAsync(dataToSign, privateKey);
109
+ const signature = ed25519.sign(dataToSign, privateKey);
128
110
  bufferView.set(signature, signaturePosition);
129
111
  }
130
112
  /** @param {string} topic @param {string | Uint8Array | Object} data @param {number} [HOPS] @param {string[]} route @param {string[]} [neighbors] */
131
- async createGossipMessage(topic, data, HOPS = 3, neighbors = [], timestamp = CLOCK.time) {
113
+ createGossipMessage(topic, data, HOPS = 3, neighbors = [], timestamp = CLOCK.time) {
132
114
  const MARKER = GOSSIP.MARKERS_BYTES[topic];
133
115
  if (MARKER === undefined) throw new Error(`Failed to create gossip message: unknown topic '${topic}'.`);
134
116
 
@@ -142,7 +124,7 @@ export class CryptoCodex {
142
124
  bufferView.set(neighborsBytes, 47); // X bytes for neighbors
143
125
  bufferView.set(dataBytes, 47 + neighborsBytes.length); // X bytes for data
144
126
  bufferView.set([Math.min(255, HOPS)], totalBytes - 1); // 1 byte for HOPS (Unsigned)
145
- await this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH - 1);
127
+ this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH - 1);
146
128
  return bufferView;
147
129
  }
148
130
  /** @param {Uint8Array} serializedMessage */
@@ -152,8 +134,8 @@ export class CryptoCodex {
152
134
  clone[serializedMessage.length - 1] = Math.max(0, hops - 1);
153
135
  return clone;
154
136
  }
155
- /** @param {string} type @param {string | Uint8Array | Object} data @param {string[]} route @param {string[]} [neighbors] */
156
- async createUnicastMessage(type, data, route, neighbors = [], timestamp = CLOCK.time) {
137
+ /** @param {string} type @param {string | Uint8Array | Object} data @param {string[]} route @param {string[]} [neighbors] @param {Uint8Array} [encryptionKey] @param {number} [timestamp] */
138
+ createUnicastMessage(type, data, route, neighbors = [], encryptionKey, timestamp = CLOCK.time) {
157
139
  const MARKER = UNICAST.MARKERS_BYTES[type];
158
140
  if (MARKER === undefined) throw new Error(`Failed to create unicast message: unknown type '${type}'.`);
159
141
  if (route.length < 2) throw new Error('Failed to create unicast message: route must have at least 2 nodes (next hop and target).');
@@ -161,23 +143,24 @@ export class CryptoCodex {
161
143
 
162
144
  const neighborsBytes = this.#idsToBytes(neighbors);
163
145
  const { dataCode, dataBytes } = this.#dataToBytes(data);
146
+ const dBytes = encryptionKey ? this.encryptData(dataBytes, encryptionKey) : dataBytes;
164
147
  const routeBytes = this.#idsToBytes(route);
165
- const totalBytes = 1 + 1 + 1 + 8 + 4 + 32 + neighborsBytes.length + 1 + routeBytes.length + dataBytes.length + IDENTITY.SIGNATURE_LENGTH;
148
+ const totalBytes = 1 + 1 + 1 + 8 + 4 + 32 + neighborsBytes.length + 1 + routeBytes.length + dBytes.length + IDENTITY.SIGNATURE_LENGTH;
166
149
  const buffer = new ArrayBuffer(totalBytes);
167
150
  const bufferView = new Uint8Array(buffer);
168
- this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dataBytes, this.publicKey);
151
+ this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dBytes, this.publicKey);
169
152
 
170
- const NDBL = neighborsBytes.length + dataBytes.length;
153
+ const NDBL = neighborsBytes.length + dBytes.length;
171
154
  bufferView.set(neighborsBytes, 47); // X bytes for neighbors
172
- bufferView.set(dataBytes, 47 + neighborsBytes.length); // X bytes for data
155
+ bufferView.set(dBytes, 47 + neighborsBytes.length); // X bytes for data
173
156
  bufferView.set([route.length], 47 + NDBL); // 1 byte for route length
174
157
  bufferView.set(routeBytes, 47 + 1 + NDBL); // X bytes for route
175
158
 
176
- await this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH);
159
+ this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH);
177
160
  return bufferView;
178
161
  }
179
162
  /** @param {Uint8Array} serialized @param {string[]} newRoute */
180
- async createReroutedUnicastMessage(serialized, newRoute) {
163
+ createReroutedUnicastMessage(serialized, newRoute) {
181
164
  if (newRoute.length < 2) throw new Error('Failed to create rerouted unicast message: route must have at least 2 nodes (next hop and target).');
182
165
  if (newRoute.length > UNICAST.MAX_HOPS) throw new Error(`Failed to create rerouted unicast message: route exceeds max hops (${UNICAST.MAX_HOPS}).`);
183
166
 
@@ -189,7 +172,7 @@ export class CryptoCodex {
189
172
  bufferView.set(this.publicKey, serialized.length); // 32 bytes for new public key
190
173
  for (let i = 0; i < routeBytesArray.length; i++) bufferView.set(routeBytesArray[i], serialized.length + 32 + (i * IDENTITY.ID_LENGTH)); // new route
191
174
 
192
- await this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH);
175
+ this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH);
193
176
  return bufferView;
194
177
  }
195
178
  /** @param {string[]} ids */
@@ -217,9 +200,9 @@ export class CryptoCodex {
217
200
 
218
201
  // MESSSAGE READING (DESERIALIZATION AND SIGNATURE VERIFICATION INCLUDED)
219
202
  /** @param {Uint8Array} publicKey @param {Uint8Array} dataToVerify @param {Uint8Array} signature */
220
- async verifySignature(publicKey, dataToVerify, signature) {
203
+ verifySignature(publicKey, dataToVerify, signature) {
221
204
  if (this.AVOID_CRYPTO) return true;
222
- return this.verifier.verifySignature(publicKey, dataToVerify, signature);
205
+ return ed25519.verify(signature, dataToVerify, publicKey);
223
206
  }
224
207
  /** @param {Uint8Array} bufferView */
225
208
  readBufferHeader(bufferView, readAssociatedId = true) {
@@ -255,8 +238,8 @@ export class CryptoCodex {
255
238
  } catch (error) { if (this.verbose > 1) console.warn(`Error deserializing ${topic || 'unknown'} gossip message:`, error.stack); }
256
239
  return null;
257
240
  }
258
- /** @param {Uint8Array | ArrayBuffer} serialized @return {DirectMessage | ReroutedDirectMessage | null} */
259
- readUnicastMessage(serialized) {
241
+ /** @param {Uint8Array | ArrayBuffer} serialized @param {import('./peer-store.mjs').PeerStore} peerStore @return {DirectMessage | ReroutedDirectMessage | null} */
242
+ readUnicastMessage(serialized, peerStore) {
260
243
  if (this.verbose > 3) console.log(`%creadUnicastMessage ${serialized.byteLength} bytes`, LOG_CSS.CRYPTO_CODEX);
261
244
  if (this.verbose > 4) console.log(`%c${serialized}`, LOG_CSS.CRYPTO_CODEX);
262
245
  try { // 1, 1, 1, 8, 4, 32, X, 1, X, 64
@@ -265,12 +248,18 @@ export class CryptoCodex {
265
248
  if (type === undefined) throw new Error(`Failed to deserialize unicast message: unknown marker byte ${d[0]}.`);
266
249
  const NDBL = neighLength + dataLength;
267
250
  const neighbors = this.#bytesToIds(serialized.slice(47, 47 + neighLength));
268
- const deserializedData = this.#bytesToData(dataCode, serialized.slice(47 + neighLength, 47 + NDBL));
269
251
  const routeLength = serialized[47 + NDBL];
270
252
  const routeBytesLength = routeLength * this.idLength;
271
253
  const signatureStart = 47 + NDBL + 1 + routeBytesLength;
272
254
  const routeBytes = serialized.slice(47 + NDBL + 1, signatureStart);
273
255
  const route = this.#bytesToIds(routeBytes);
256
+
257
+ const destId = route[route.length - 1];
258
+ const d = type === 'private_message' && this.id === destId
259
+ ? this.decryptData(serialized.slice(47 + neighLength, 47 + NDBL), peerStore.privacy[this.#idFromPublicKey(pubkey)]?.sharedSecret)
260
+ : serialized.slice(47 + neighLength, 47 + NDBL);
261
+
262
+ const deserializedData = this.id === destId ? this.#bytesToData(dataCode, d) : d;
274
263
  const initialMessageEnd = signatureStart + IDENTITY.SIGNATURE_LENGTH;
275
264
  const signature = serialized.slice(signatureStart, initialMessageEnd);
276
265
  const isPatched = (serialized.length > initialMessageEnd);
@@ -288,7 +277,8 @@ export class CryptoCodex {
288
277
  #bytesToIds(serialized) {
289
278
  const ids = [];
290
279
  const idLength = this.idLength;
291
- if (serialized.length % idLength !== 0) throw new Error('Failed to parse ids: invalid serialized length.');
280
+ if (serialized.length % idLength !== 0)
281
+ throw new Error('Failed to parse ids: invalid serialized length.');
292
282
 
293
283
  for (let i = 0; i < serialized.length / idLength; i++) {
294
284
  const idBytes = serialized.slice(i * idLength, (i + 1) * idLength);
package/core/gossip.mjs CHANGED
@@ -103,9 +103,9 @@ export class Gossip {
103
103
  }
104
104
  /** Gossip a message to all connected peers > will be forwarded to all peers
105
105
  * @param {string | Uint8Array | Object} data @param {string} topic default: 'gossip' @param {number} [HOPS] */
106
- async broadcastToAll(data, topic = 'gossip', HOPS) {
106
+ broadcastToAll(data, topic = 'gossip', HOPS) {
107
107
  const hops = HOPS || GOSSIP.HOPS[topic] || GOSSIP.HOPS.default;
108
- const serializedMessage = await this.cryptoCodex.createGossipMessage(topic, data, hops, this.peerStore.neighborsList);
108
+ const serializedMessage = this.cryptoCodex.createGossipMessage(topic, data, hops, this.peerStore.neighborsList);
109
109
  if (!this.bloomFilter.addMessage(serializedMessage)) return; // avoid sending duplicate messages
110
110
  if (this.verbose > 3) console.log(`(${this.id}) Gossip ${topic}, to ${JSON.stringify(this.peerStore.neighborsList)}: ${data}`);
111
111
  for (const peerId of this.peerStore.neighborsList) this.#broadcastToPeer(peerId, serializedMessage);
@@ -51,12 +51,12 @@ export class NodeServices {
51
51
  #startWebSocketServer(domain = 'localhost', port = SERVICE.PORT) {
52
52
  this.wsServer = new TRANSPORTS.WS_SERVER({ port, host: domain });
53
53
  this.wsServer.on('error', (error) => console.error(`WebSocket error on Node #${this.id}:`, error));
54
- this.wsServer.on('connection', async (ws) => {
54
+ this.wsServer.on('connection', (ws) => {
55
55
  ws.on('close', () => { if (remoteId) for (const cb of this.peerStore.callbacks.disconnect) cb(remoteId, 'in'); });
56
56
  ws.on('error', (error) => console.error(`WebSocket error on Node #${this.id} with peer ${remoteId}:`, error.stack));
57
57
 
58
58
  let remoteId;
59
- ws.on('message', async (data) => { // When peer proves his id, we can handle data normally
59
+ ws.on('message', (data) => { // When peer proves his id, we can handle data normally
60
60
  if (remoteId) for (const cb of this.peerStore.callbacks.data) cb(remoteId, data);
61
61
  else { // FIRST MESSAGE SHOULD BE HANDSHAKE WITH ID
62
62
  const d = new Uint8Array(data); if (d[0] > 127) return; // not unicast, ignore
@@ -68,7 +68,7 @@ export class NodeServices {
68
68
 
69
69
  const { signatureStart, pubkey, signature } = message;
70
70
  const signedData = d.subarray(0, signatureStart);
71
- if (!await this.cryptoCodex.verifySignature(pubkey, signedData, signature)) return;
71
+ if (!this.cryptoCodex.verifySignature(pubkey, signedData, signature)) return;
72
72
 
73
73
  remoteId = route[0];
74
74
  this.peerStore.digestPeerNeighbors(remoteId, neighborsList); // Update known store
@@ -79,8 +79,7 @@ export class NodeServices {
79
79
  }
80
80
  });
81
81
 
82
- const handshakeMsg = await this.cryptoCodex.createUnicastMessage('handshake', null, [this.id, this.id], this.peerStore.neighborsList);
83
- ws.send(handshakeMsg);
82
+ ws.send(this.cryptoCodex.createUnicastMessage('handshake', null, [this.id, this.id], this.peerStore.neighborsList));
84
83
  });
85
84
  }
86
85
  #startSTUNServer(host = 'localhost', port = SERVICE.PORT + 1) {
package/core/node.mjs CHANGED
@@ -17,9 +17,9 @@ import { NodeServices } from './node-services.mjs';
17
17
  * @param {string} [options.domain] If provided, the node will operate as a public node and start necessary services (e.g., WebSocket server)
18
18
  * @param {number} [options.port] If provided, the node will listen on this port (default: SERVICE.PORT)
19
19
  * @param {number} [options.verbose] Verbosity level for logging (default: NODE.DEFAULT_VERBOSE) */
20
- export async function createPublicNode(options) {
20
+ export async function createPublicNode(options = {}) {
21
21
  const verbose = options.verbose !== undefined ? options.verbose : NODE.DEFAULT_VERBOSE;
22
- const domain = options.domain || undefined;
22
+ const domain = options.domain || "localhost";
23
23
  const codex = options.cryptoCodex || new CryptoCodex(undefined, verbose);
24
24
  const clockSync = CLOCK.sync(verbose);
25
25
  if (!codex.publicKey) await codex.generate(domain ? true : false);
@@ -82,7 +82,7 @@ export class Node {
82
82
  const { arbiter, peerStore, messager, gossip, topologist } = this;
83
83
 
84
84
  // SETUP TRANSPORTS LISTENERS
85
- peerStore.on('signal', (peerId, data) => this.sendMessage(peerId, data, 'signal_answer')); // answer created => send it to offerer
85
+ peerStore.on('signal', (peerId, data) => this.sendMessage(peerId, data, { type: 'signal_answer' })); // answer created => send it to offerer
86
86
  peerStore.on('connect', (peerId, direction) => this.#onConnect(peerId, direction));
87
87
  peerStore.on('disconnect', (peerId, direction) => this.#onDisconnect(peerId, direction));
88
88
  peerStore.on('data', (peerId, data) => this.#onData(peerId, data));
@@ -90,6 +90,7 @@ export class Node {
90
90
  // UNICAST LISTENERS
91
91
  messager.on('signal_answer', (senderId, data) => topologist.handleIncomingSignal(senderId, data));
92
92
  messager.on('signal_offer', (senderId, data) => topologist.handleIncomingSignal(senderId, data));
93
+ messager.on('privacy', (senderId, data) => this.#handlePrivacy(senderId, data));
93
94
 
94
95
  // GOSSIP LISTENERS
95
96
  gossip.on('signal_offer', (senderId, data, HOPS) => topologist.handleIncomingSignal(senderId, data, HOPS));
@@ -108,9 +109,9 @@ export class Node {
108
109
 
109
110
  const isHoverNeighbored = this.peerStore.neighborsList.length >= DISCOVERY.TARGET_NEIGHBORS_COUNT + this.halfTarget;
110
111
  const dispatchEvents = () => {
111
- //this.sendMessage(peerId, this.id, 'handshake'); // send it in both case, no doubt...
112
+ //this.sendMessage(peerId, this.id, { type: 'handshake' }); // send it in both case, no doubt...
112
113
  if (DISCOVERY.ON_CONNECT_DISPATCH.OVER_NEIGHBORED && isHoverNeighbored)
113
- this.broadcast([], 'over_neighbored'); // inform my neighbors that I am over neighbored
114
+ this.broadcast([], { type: 'over_neighbored' }); // inform my neighbors that I am over neighbored
114
115
  if (DISCOVERY.ON_CONNECT_DISPATCH.SHARE_HISTORY)
115
116
  if (this.peerStore.getUpdatedPeerConnectionsCount(peerId) <= 1) this.gossip.sendGossipHistoryToPeer(peerId);
116
117
  };
@@ -160,12 +161,69 @@ export class Node {
160
161
  }
161
162
 
162
163
  /** Broadcast a message to all connected peers or to a specified peer
163
- * @param {string | Uint8Array | Object} data @param {string} topic default: 'gossip' @param {string} [targetId] default: broadcast to all
164
- * @param {number} [timestamp] default: CLOCK.time @param {number} [HOPS] default: GOSSIP.HOPS[topic] || GOSSIP.HOPS.default */
165
- async broadcast(data, topic, HOPS) { return this.gossip.broadcastToAll(data, topic, HOPS); }
164
+ * @param {string | Uint8Array | Object} data @param {string} [targetId] default: broadcast to all
165
+ * @param {number} [timestamp] default: CLOCK.time
166
+ * @param {Object} [options]
167
+ * @param {string} [options.type] default: 'gossip'
168
+ * @param {number} [options.HOPS] default: GOSSIP.HOPS[topic] || GOSSIP.HOPS.default */
169
+ broadcast(data, options = {}) {
170
+ const { type, HOPS } = options;
171
+ return this.gossip.broadcastToAll(data, topic, HOPS);
172
+ }
166
173
 
167
- /** @param {string} remoteId @param {string | Uint8Array | Object} data @param {string} type */
168
- async sendMessage(remoteId, data, type, spread = 1) { return this.messager.sendUnicast(remoteId, data, type, spread); }
174
+ /** Send a unicast message to a specific peer
175
+ * @param {string} remoteId @param {string | Uint8Array | Object} data
176
+ * @param {Object} [options]
177
+ * @param {'message' | 'private_message'} [options.type] default: 'message'
178
+ * @param {number} [options.spread=1] Number of neighbors used to relay the message */
179
+ async sendMessage(remoteId, data, options = {}) {
180
+ const { type, spread } = options;
181
+ const privacy = type === 'private_message' ? await this.getPeerPrivacy(remoteId) : null;
182
+ if (privacy && !privacy?.sharedSecret) {
183
+ this.verbose > 1 ? console.warn(`Cannot send encrypted message to ${remoteId} as shared secret could not be established.`) : null;
184
+ return false;
185
+ }
186
+
187
+ return this.messager.sendUnicast(remoteId, data, type, spread);
188
+ }
189
+ /** Send a private message to a specific peer, ensuring shared secret is established
190
+ * @param {string} remoteId @param {string | Uint8Array | Object} data
191
+ * @param {Object} [options]
192
+ * @param {number} [options.spread=1] Number of neighbors used to relay the message */
193
+ async sendPrivateMessage(remoteId, data, options = {}) {
194
+ const o = { ...options, type: 'private_message' };
195
+ return this.sendMessage(remoteId, data, o);
196
+ }
197
+ // Building this function, she can be moved to another class later
198
+ async getPeerPrivacy(peerId, retry = 20) {
199
+ let p = this.peerStore.privacy[peerId];
200
+ if (!p) {
201
+ this.peerStore.privacy[peerId] = this.cryptoCodex.generateEphemeralX25519Keypair();
202
+ p = this.peerStore.privacy[peerId];
203
+ }
204
+
205
+ let nextMessageIn = 1;
206
+ while (!p?.sharedSecret && retry-- > 0) {
207
+ if (nextMessageIn-- <= 0) {
208
+ this.messager.sendUnicast(peerId, p.myPub, 'privacy');
209
+ nextMessageIn = 5;
210
+ }
211
+ await new Promise(r => setTimeout(r, 100));
212
+ p = this.peerStore.privacy[peerId];
213
+ }
214
+ return p;
215
+ }
216
+ /** @param {string} senderId @param {Uint8Array} peerPub */
217
+ #handlePrivacy(senderId, peerPub) {
218
+ if (this.peerStore.privacy[senderId]?.sharedSecret) return; // already have shared secret
219
+
220
+ if (!this.peerStore.privacy[senderId])
221
+ this.peerStore.privacy[senderId] = this.cryptoCodex.generateEphemeralX25519Keypair();
222
+
223
+ this.peerStore.privacy[senderId].peerPub = peerPub;
224
+ this.peerStore.privacy[senderId].sharedSecret = this.cryptoCodex.computeX25519SharedSecret(this.peerStore.privacy[senderId].myPriv, peerPub);
225
+ this.messager.sendUnicast(senderId, this.peerStore.privacy[senderId].myPub, 'privacy');
226
+ }
169
227
 
170
228
  /** Send a connection request to a peer */
171
229
  async tryConnectToPeer(targetId = 'toto', retry = 10) {
@@ -174,7 +232,7 @@ export class Node {
174
232
  const { offerHash, readyOffer } = this.peerStore.offerManager.bestReadyOffer(100, false);
175
233
  if (!offerHash || !readyOffer) await new Promise(r => setTimeout(r, 1000)); // build in progress...
176
234
  else {
177
- this.messager.sendUnicast(targetId, { signal: readyOffer.signal, offerHash }, 'signal_offer', 1);
235
+ this.messager.sendUnicast(targetId, { signal: readyOffer.signal, offerHash }, 'signal_offer');
178
236
  return;
179
237
  }
180
238
  } while (retry-- > 0);
@@ -201,7 +259,14 @@ export class Node {
201
259
 
202
260
  /** Triggered when a new message is received.
203
261
  * @param {function} callback can use arguments: (senderId:string, data:any) */
204
- onMessageData(callback) { this.messager.on('message', callback); }
262
+ onMessageData(callback, includesPrivate = true) {
263
+ this.messager.on('message', callback);
264
+ if (includesPrivate) this.messager.on('private_message', callback);
265
+ }
266
+
267
+ /** Triggered when a new private message is received.
268
+ * @param {function} callback can use arguments: (senderId:string, data:any) */
269
+ onPrivateMessageData(callback) { this.messager.on('private_message', callback); }
205
270
 
206
271
  /** Triggered when a new gossip message is received.
207
272
  * @param {function} callback can use arguments: (senderId:string, data:any, HOPS:number) */
@@ -20,6 +20,13 @@ export class KnownPeer { // known peer, not necessarily connected
20
20
  }
21
21
  }
22
22
 
23
+ class Privacy {
24
+ /** @type {Uint8Array} */ sharedSecret;
25
+ /** @type {Uint8Array} */ myPub;
26
+ /** @type {Uint8Array} */ myPriv;
27
+ /** @type {Uint8Array} */ peerPub;
28
+ }
29
+
23
30
  export class PeerConnection { // WebSocket or WebRTC connection wrapper
24
31
  peerId; transportInstance; isWebSocket; direction; pendingUntil; connStartTime;
25
32
 
@@ -46,6 +53,7 @@ export class PeerStore { // Manages all peers informations and connections (WebS
46
53
  /** @type {Record<string, PeerConnection>} */ connected = {};
47
54
  /** @type {Record<string, KnownPeer>} */ known = {}; // known peers store
48
55
  /** @type {number} */ knownCount = 0;
56
+ /** @type {Record<string, Privacy>} */ privacy = {}; // peerId, Privacy settings
49
57
  /** @type {Record<string, number>} */ kick = {}; // peerId, timestamp until kick expires
50
58
  /** @type {Record<string, Function[]>} */ callbacks = {
51
59
  'connect': [(peerId, direction) => this.#handleConnect(peerId, direction)],
@@ -169,13 +169,13 @@ export class Topologist {
169
169
  let remoteId = null;
170
170
  const ws = new TRANSPORTS.WS_CLIENT(this.#getFullWsUrl(publicUrl)); ws.binaryType = 'arraybuffer';
171
171
  ws.onerror = (error) => console.error(`WebSocket error:`, error.stack);
172
- ws.onopen = async () => {
172
+ ws.onopen = () => {
173
173
  this.bootstrapsConnectionState.set(publicUrl, true);
174
174
  ws.onclose = () => {
175
175
  this.bootstrapsConnectionState.set(publicUrl, false);
176
176
  for (const cb of this.peerStore.callbacks.disconnect) cb(remoteId, 'out');
177
177
  }
178
- ws.onmessage = async (data) => {
178
+ ws.onmessage = (data) => {
179
179
  if (remoteId) for (const cb of this.peerStore.callbacks.data) cb(remoteId, data.data);
180
180
  else { // FIRST MESSAGE SHOULD BE HANDSHAKE WITH ID
181
181
  const d = new Uint8Array(data.data); if (d[0] > 127) return; // not unicast, ignore
@@ -187,7 +187,7 @@ export class Topologist {
187
187
 
188
188
  const { signatureStart, pubkey, signature } = message;
189
189
  const signedData = d.subarray(0, signatureStart);
190
- if (!await this.cryptoCodex.verifySignature(pubkey, signedData, signature)) return;
190
+ if (!this.cryptoCodex.verifySignature(pubkey, signedData, signature)) return;
191
191
 
192
192
  remoteId = route[0];
193
193
  this.peerStore.digestPeerNeighbors(remoteId, neighborsList); // Update known store
@@ -197,8 +197,8 @@ export class Topologist {
197
197
  for (const cb of this.peerStore.callbacks.connect) cb(remoteId, 'out');
198
198
  }
199
199
  };
200
- const handshakeMsg = await this.cryptoCodex.createUnicastMessage('handshake', null, [this.id, this.id], this.peerStore.neighborsList);
201
- ws.send(handshakeMsg);
200
+
201
+ ws.send(this.cryptoCodex.createUnicastMessage('handshake', null, [this.id, this.id], this.peerStore.neighborsList));
202
202
  };
203
203
  }
204
204
  #tryToSpreadSDP(nonPublicNeighborsCount = 0, isHalfReached = false) { // LOOP TO SELECT ONE UNSEND READY OFFER AND BROADCAST IT
@@ -252,7 +252,7 @@ export class Topologist {
252
252
  if (sentTo.has(peerId)) continue;
253
253
  if (sentTo.size === 0) readyOffer.sentCounter++;
254
254
  sentTo.set(peerId, true);
255
- this.messager.sendUnicast(peerId, { signal: readyOffer.signal, offerHash }, 'signal_offer', 1);
255
+ this.messager.sendUnicast(peerId, { signal: readyOffer.signal, offerHash }, 'signal_offer');
256
256
  if (sentTo.size >= 12) break; // limit to 12 unicast max
257
257
  }
258
258
  }
package/core/unicast.mjs CHANGED
@@ -75,16 +75,14 @@ export class UnicastMessager {
75
75
  }
76
76
  /** Send unicast message to a target
77
77
  * @param {string} remoteId @param {string | Uint8Array | Object} data @param {string} type
78
- * @param {number} [spread] Max neighbors used to relay the message, default: 1 */
79
- async sendUnicast(remoteId, data, type = 'message', spread = 1) {
78
+ * @param {number} [spread] *Optional* Max neighbors used to relay the message, default: 1 */
79
+ sendUnicast(remoteId, data, type = 'message', spread = 1) {
80
80
  if (remoteId === this.id) return false;
81
81
 
82
82
  const builtResult = this.pathFinder.buildRoutes(remoteId, this.maxRoutes, this.maxHops, this.maxNodes, true);
83
83
  if (!builtResult.success) return false;
84
-
85
- // Caution: re-routing usage who can involve insane results
86
- const destinations = [];
87
- const createdMessagePromises = [];
84
+
85
+ const encryptionKey = type === 'private_message' ? this.peerStore.privacy[remoteId]?.sharedSecret : undefined;
88
86
  const finalSpread = builtResult.success === 'blind' ? 1 : spread; // Spread only if re-routing is false
89
87
  for (let i = 0; i < Math.min(finalSpread, builtResult.routes.length); i++) {
90
88
  const route = builtResult.routes[i].path;
@@ -92,14 +90,10 @@ export class UnicastMessager {
92
90
  if (this.verbose > 1) console.warn(`Cannot send unicast message to ${remoteId} as route exceeds maxHops (${UNICAST.MAX_HOPS}). BFS incurred.`);
93
91
  continue; // too long route
94
92
  }
95
-
96
- destinations.push(route[1]); // send to next peer
97
- createdMessagePromises.push(this.cryptoCodex.createUnicastMessage(type, data, route, this.peerStore.neighborsList));
93
+
94
+ const msg = this.cryptoCodex.createUnicastMessage(type, data, route, this.peerStore.neighborsList, encryptionKey);
95
+ this.#sendMessageToPeer(route[1], msg);
98
96
  }
99
-
100
- const createdMessages = await Promise.all(createdMessagePromises);
101
- for (let i = 0; i < createdMessages.length; i++)
102
- this.#sendMessageToPeer(destinations[i], createdMessages[i]);
103
97
  return true;
104
98
  }
105
99
  /** @param {string} targetId @param {Uint8Array} serialized */
@@ -119,7 +113,7 @@ export class UnicastMessager {
119
113
  if (this.arbiter.isBanished(from)) return this.verbose >= 3 ? console.info(`%cReceived direct message from banned peer ${from}, ignoring.`, 'color: red;') : null;
120
114
  if (!this.arbiter.countMessageBytes(from, serialized.byteLength, 'unicast')) return; // ignore if flooding/banished
121
115
 
122
- const message = this.cryptoCodex.readUnicastMessage(serialized);
116
+ const message = this.cryptoCodex.readUnicastMessage(serialized, this.peerStore);
123
117
  if (!message) return this.arbiter.countPeerAction(from, 'WRONG_SERIALIZATION');
124
118
  const isOk = await this.arbiter.digestMessage(from, message, serialized);
125
119
  if (!isOk) return; // invalid message or from banished peer
@@ -155,7 +149,7 @@ export class UnicastMessager {
155
149
  return; // too long route
156
150
  }
157
151
 
158
- const patchedMessage = await this.cryptoCodex.createReroutedUnicastMessage(serialized, newRoute);
152
+ const patchedMessage = this.cryptoCodex.createReroutedUnicastMessage(serialized, newRoute);
159
153
  const nextPeerId = newRoute[selfPosition + 1];
160
154
  this.#sendMessageToPeer(nextPeerId, patchedMessage);
161
155
  }