@hive-p2p/browser 1.0.69 → 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/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,7 +3,8 @@ 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
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';
7
8
 
8
9
  export class CryptoCodex {
9
10
  argon2 = new Argon2Unified();
@@ -52,6 +53,7 @@ export class CryptoCodex {
52
53
  const { bitsString } = await this.argon2.hash(publicKey, 'HiveP2P', IDENTITY.ARGON2_MEM) || {};
53
54
  if (bitsString && bitsString.startsWith('0'.repeat(IDENTITY.DIFFICULTY))) return true;
54
55
  }
56
+ /** @param {Uint8Array} publicKey */
55
57
  #idFromPublicKey(publicKey) {
56
58
  if (IDENTITY.ARE_IDS_HEX) return this.converter.bytesToHex(publicKey.slice(0, this.idLength), IDENTITY.ID_LENGTH);
57
59
  return this.converter.bytesToString(publicKey.slice(0, IDENTITY.ID_LENGTH));
@@ -74,7 +76,32 @@ export class CryptoCodex {
74
76
  if (this.verbose > 0) console.log(`%cFAILED to generate id after ${maxIterations} iterations. Try lowering the difficulty.`, LOG_CSS.CRYPTO_CODEX);
75
77
  }
76
78
 
77
- // 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)
78
105
  /** @param {Uint8Array} bufferView @param {Uint8Array} privateKey @param {number} [signaturePosition] */
79
106
  signBufferViewAndAppendSignature(bufferView, privateKey, signaturePosition = bufferView.length - IDENTITY.SIGNATURE_LENGTH) {
80
107
  if (this.AVOID_CRYPTO) return;
@@ -107,8 +134,8 @@ export class CryptoCodex {
107
134
  clone[serializedMessage.length - 1] = Math.max(0, hops - 1);
108
135
  return clone;
109
136
  }
110
- /** @param {string} type @param {string | Uint8Array | Object} data @param {string[]} route @param {string[]} [neighbors] */
111
- 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) {
112
139
  const MARKER = UNICAST.MARKERS_BYTES[type];
113
140
  if (MARKER === undefined) throw new Error(`Failed to create unicast message: unknown type '${type}'.`);
114
141
  if (route.length < 2) throw new Error('Failed to create unicast message: route must have at least 2 nodes (next hop and target).');
@@ -116,15 +143,16 @@ export class CryptoCodex {
116
143
 
117
144
  const neighborsBytes = this.#idsToBytes(neighbors);
118
145
  const { dataCode, dataBytes } = this.#dataToBytes(data);
146
+ const dBytes = encryptionKey ? this.encryptData(dataBytes, encryptionKey) : dataBytes;
119
147
  const routeBytes = this.#idsToBytes(route);
120
- 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;
121
149
  const buffer = new ArrayBuffer(totalBytes);
122
150
  const bufferView = new Uint8Array(buffer);
123
- this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dataBytes, this.publicKey);
151
+ this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dBytes, this.publicKey);
124
152
 
125
- const NDBL = neighborsBytes.length + dataBytes.length;
153
+ const NDBL = neighborsBytes.length + dBytes.length;
126
154
  bufferView.set(neighborsBytes, 47); // X bytes for neighbors
127
- bufferView.set(dataBytes, 47 + neighborsBytes.length); // X bytes for data
155
+ bufferView.set(dBytes, 47 + neighborsBytes.length); // X bytes for data
128
156
  bufferView.set([route.length], 47 + NDBL); // 1 byte for route length
129
157
  bufferView.set(routeBytes, 47 + 1 + NDBL); // X bytes for route
130
158
 
@@ -210,8 +238,8 @@ export class CryptoCodex {
210
238
  } catch (error) { if (this.verbose > 1) console.warn(`Error deserializing ${topic || 'unknown'} gossip message:`, error.stack); }
211
239
  return null;
212
240
  }
213
- /** @param {Uint8Array | ArrayBuffer} serialized @return {DirectMessage | ReroutedDirectMessage | null} */
214
- readUnicastMessage(serialized) {
241
+ /** @param {Uint8Array | ArrayBuffer} serialized @param {import('./peer-store.mjs').PeerStore} peerStore @return {DirectMessage | ReroutedDirectMessage | null} */
242
+ readUnicastMessage(serialized, peerStore) {
215
243
  if (this.verbose > 3) console.log(`%creadUnicastMessage ${serialized.byteLength} bytes`, LOG_CSS.CRYPTO_CODEX);
216
244
  if (this.verbose > 4) console.log(`%c${serialized}`, LOG_CSS.CRYPTO_CODEX);
217
245
  try { // 1, 1, 1, 8, 4, 32, X, 1, X, 64
@@ -220,12 +248,18 @@ export class CryptoCodex {
220
248
  if (type === undefined) throw new Error(`Failed to deserialize unicast message: unknown marker byte ${d[0]}.`);
221
249
  const NDBL = neighLength + dataLength;
222
250
  const neighbors = this.#bytesToIds(serialized.slice(47, 47 + neighLength));
223
- const deserializedData = this.#bytesToData(dataCode, serialized.slice(47 + neighLength, 47 + NDBL));
224
251
  const routeLength = serialized[47 + NDBL];
225
252
  const routeBytesLength = routeLength * this.idLength;
226
253
  const signatureStart = 47 + NDBL + 1 + routeBytesLength;
227
254
  const routeBytes = serialized.slice(47 + NDBL + 1, signatureStart);
228
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;
229
263
  const initialMessageEnd = signatureStart + IDENTITY.SIGNATURE_LENGTH;
230
264
  const signature = serialized.slice(signatureStart, initialMessageEnd);
231
265
  const isPatched = (serialized.length > initialMessageEnd);
@@ -243,7 +277,8 @@ export class CryptoCodex {
243
277
  #bytesToIds(serialized) {
244
278
  const ids = [];
245
279
  const idLength = this.idLength;
246
- 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.');
247
282
 
248
283
  for (let i = 0; i < serialized.length / idLength; i++) {
249
284
  const idBytes = serialized.slice(i * idLength, (i + 1) * idLength);
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
- 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
- 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)],
@@ -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,14 +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 */
78
+ * @param {number} [spread] *Optional* Max neighbors used to relay the message, default: 1 */
79
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
84
+
85
+ const encryptionKey = type === 'private_message' ? this.peerStore.privacy[remoteId]?.sharedSecret : undefined;
86
86
  const finalSpread = builtResult.success === 'blind' ? 1 : spread; // Spread only if re-routing is false
87
87
  for (let i = 0; i < Math.min(finalSpread, builtResult.routes.length); i++) {
88
88
  const route = builtResult.routes[i].path;
@@ -91,7 +91,8 @@ export class UnicastMessager {
91
91
  continue; // too long route
92
92
  }
93
93
 
94
- this.#sendMessageToPeer(route[1], this.cryptoCodex.createUnicastMessage(type, data, route, this.peerStore.neighborsList));
94
+ const msg = this.cryptoCodex.createUnicastMessage(type, data, route, this.peerStore.neighborsList, encryptionKey);
95
+ this.#sendMessageToPeer(route[1], msg);
95
96
  }
96
97
  return true;
97
98
  }
@@ -112,7 +113,7 @@ export class UnicastMessager {
112
113
  if (this.arbiter.isBanished(from)) return this.verbose >= 3 ? console.info(`%cReceived direct message from banned peer ${from}, ignoring.`, 'color: red;') : null;
113
114
  if (!this.arbiter.countMessageBytes(from, serialized.byteLength, 'unicast')) return; // ignore if flooding/banished
114
115
 
115
- const message = this.cryptoCodex.readUnicastMessage(serialized);
116
+ const message = this.cryptoCodex.readUnicastMessage(serialized, this.peerStore);
116
117
  if (!message) return this.arbiter.countPeerAction(from, 'WRONG_SERIALIZATION');
117
118
  const isOk = await this.arbiter.digestMessage(from, message, serialized);
118
119
  if (!isOk) return; // invalid message or from banished peer
@@ -148,7 +149,7 @@ export class UnicastMessager {
148
149
  return; // too long route
149
150
  }
150
151
 
151
- const patchedMessage = await this.cryptoCodex.createReroutedUnicastMessage(serialized, newRoute);
152
+ const patchedMessage = this.cryptoCodex.createReroutedUnicastMessage(serialized, newRoute);
152
153
  const nextPeerId = newRoute[selfPosition + 1];
153
154
  this.#sendMessageToPeer(nextPeerId, patchedMessage);
154
155
  }