hive-p2p 1.0.0

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.
@@ -0,0 +1,249 @@
1
+ import { CLOCK, SIMULATION, NODE, IDENTITY, GOSSIP, UNICAST, LOG_CSS } from './parameters.mjs';
2
+ import { GossipMessage } from './gossip.mjs';
3
+ import { DirectMessage, ReroutedDirectMessage } from './unicast.mjs';
4
+ import { Converter } from '../services/converter.mjs';
5
+ import { ed25519, Argon2Unified } from '../services/cryptos.mjs';
6
+
7
+ export class CryptoCodex {
8
+ argon2 = new Argon2Unified();
9
+ converter = new Converter();
10
+ AVOID_CRYPTO = false;
11
+ verbose = NODE.DEFAULT_VERBOSE;
12
+ /** @type {string} */ id;
13
+ /** @type {Uint8Array} */ publicKey;
14
+ /** @type {Uint8Array} */ privateKey;
15
+
16
+ /** @param {string} [nodeId] If provided: used to generate a fake keypair > disable crypto operations */
17
+ constructor(nodeId) {
18
+ if (!nodeId) return; // IF NOT PROVIDED: generate() should be called.
19
+ this.AVOID_CRYPTO = true;
20
+ this.id = nodeId.padEnd(IDENTITY.ID_LENGTH, ' ').slice(0, IDENTITY.ID_LENGTH);
21
+ this.privateKey = new Uint8Array(32).fill(0); this.publicKey = new Uint8Array(32).fill(0);
22
+ const idBytes = new TextEncoder().encode(this.id); // use nodeId to create a fake public key
23
+ for (let i = 0; i < IDENTITY.ID_LENGTH; i++) this.publicKey[i] = idBytes[i];
24
+ }
25
+
26
+ // IDENTITY
27
+ /** @param {string} id Check the first character against the PUBLIC_PREFIX */
28
+ static isPublicNode(id) { return (IDENTITY.ARE_IDS_HEX ? Converter.hexToBits(id[0]) : id).startsWith(IDENTITY.PUBLIC_PREFIX); }
29
+ /** @param {string} id */
30
+ get idLength() { return IDENTITY.ARE_IDS_HEX ? IDENTITY.ID_LENGTH / 2 : IDENTITY.ID_LENGTH; }
31
+ isPublicNode(id) { return CryptoCodex.isPublicNode(id); }
32
+ /** @param {boolean} asPublicNode @param {Uint8Array} [seed] The privateKey. DON'T USE IN SIMULATION */
33
+ async generate(asPublicNode, seed) { // Generate Ed25519 keypair cross-platform | set id only for simulator
34
+ if (this.nodeId) return;
35
+ await this.#generateAntiSybilIdentity(seed, asPublicNode);
36
+ if (!this.id) throw new Error('Failed to generate identity');
37
+ }
38
+ /** Check if the pubKey meets the difficulty using Argon2 derivation @param {Uint8Array} publicKey */
39
+ async pubkeyDifficultyCheck(publicKey) {
40
+ if (this.AVOID_CRYPTO || !IDENTITY.DIFFICULTY) return true;
41
+ const { bitsString } = await this.argon2.hash(publicKey, 'HiveP2P', IDENTITY.ARGON2_MEM) || {};
42
+ if (bitsString && bitsString.startsWith('0'.repeat(IDENTITY.DIFFICULTY))) return true;
43
+ }
44
+ #idFromPublicKey(publicKey) {
45
+ if (IDENTITY.ARE_IDS_HEX) return this.converter.bytesToHex(publicKey.slice(0, this.idLength), IDENTITY.ID_LENGTH);
46
+ return this.converter.bytesToString(publicKey.slice(0, IDENTITY.ID_LENGTH));
47
+ }
48
+ /** @param {Uint8Array} seed The privateKey. @param {boolean} asPublicNode */
49
+ async #generateAntiSybilIdentity(seed, asPublicNode) {
50
+ const maxIterations = (2 ** IDENTITY.DIFFICULTY) * 100; // avoid infinite loop
51
+ for (let i = 0; i < maxIterations; i++) { // avoid infinite loop
52
+ const { secretKey, publicKey } = ed25519.keygen(seed);
53
+ const id = this.#idFromPublicKey(publicKey);
54
+ if (asPublicNode && !this.isPublicNode(id)) continue; // Check prefix
55
+ if (!asPublicNode && this.isPublicNode(id)) continue; // Check prefix
56
+ if (!await this.pubkeyDifficultyCheck(publicKey)) continue; // Check difficulty
57
+
58
+ this.id = id;
59
+ this.privateKey = secretKey; this.publicKey = publicKey;
60
+ if (this.verbose > 2) console.log(`%cNode generated id: ${this.id} (isPublic: ${asPublicNode}, difficulty: ${IDENTITY.DIFFICULTY}) after ${((i + 1) / 2).toFixed(1)} iterations`, LOG_CSS.CRYPTO_CODEX);
61
+ return;
62
+ }
63
+ if (this.verbose > 0) console.log(`%cFAILED to generate id after ${maxIterations} iterations. Try lowering the difficulty.`, LOG_CSS.CRYPTO_CODEX);
64
+ }
65
+
66
+ // MESSSAGE CREATION (SERIALIZATION AND SIGNATURE INCLUDED)
67
+ signBufferViewAndAppendSignature(bufferView, privateKey, signaturePosition = bufferView.length - IDENTITY.SIGNATURE_LENGTH) {
68
+ if (this.AVOID_CRYPTO) return;
69
+ const dataToSign = bufferView.subarray(0, signaturePosition);
70
+ bufferView.set(ed25519.sign(dataToSign, privateKey), signaturePosition);
71
+ }
72
+ /** @param {string} topic @param {string | Uint8Array | Object} data @param {number} [HOPS] @param {string[]} route @param {string[]} [neighbors] */
73
+ createGossipMessage(topic, data, HOPS = 3, neighbors = [], timestamp = CLOCK.time) {
74
+ const MARKER = GOSSIP.MARKERS_BYTES[topic];
75
+ if (MARKER === undefined) throw new Error(`Failed to create gossip message: unknown topic '${topic}'.`);
76
+
77
+ const neighborsBytes = this.#idsToBytes(neighbors);
78
+ const { dataCode, dataBytes } = this.#dataToBytes(data);
79
+ const totalBytes = 1 + 1 + 1 + 8 + 4 + 32 + neighborsBytes.length + dataBytes.length + IDENTITY.SIGNATURE_LENGTH + 1;
80
+ const buffer = new ArrayBuffer(totalBytes);
81
+ const bufferView = new Uint8Array(buffer);
82
+ this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dataBytes, this.publicKey);
83
+
84
+ bufferView.set(neighborsBytes, 47); // X bytes for neighbors
85
+ bufferView.set(dataBytes, 47 + neighborsBytes.length); // X bytes for data
86
+ bufferView.set([Math.min(255, HOPS)], totalBytes - 1); // 1 byte for HOPS (Unsigned)
87
+ this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH - 1);
88
+ return bufferView;
89
+ }
90
+ /** @param {Uint8Array} serializedMessage */
91
+ decrementGossipHops(serializedMessage) { // Here we just need to decrement the HOPS value => last byte of the message
92
+ const clone = new Uint8Array(serializedMessage); // avoid modifying the original message
93
+ const hops = serializedMessage[serializedMessage.length - 1];
94
+ clone[serializedMessage.length - 1] = Math.max(0, hops - 1);
95
+ return clone;
96
+ }
97
+ /** @param {string} type @param {string | Uint8Array | Object} data @param {string[]} route @param {string[]} [neighbors] */
98
+ createUnicastMessage(type, data, route, neighbors = [], timestamp = CLOCK.time) {
99
+ const MARKER = UNICAST.MARKERS_BYTES[type];
100
+ if (MARKER === undefined) throw new Error(`Failed to create unicast message: unknown type '${type}'.`);
101
+ if (route.length < 2) throw new Error('Failed to create unicast message: route must have at least 2 nodes (next hop and target).');
102
+ if (route.length > UNICAST.MAX_HOPS) throw new Error(`Failed to create unicast message: route exceeds max hops (${UNICAST.MAX_HOPS}).`);
103
+
104
+ const neighborsBytes = this.#idsToBytes(neighbors);
105
+ const { dataCode, dataBytes } = this.#dataToBytes(data);
106
+ const routeBytes = this.#idsToBytes(route);
107
+ const totalBytes = 1 + 1 + 1 + 8 + 4 + 32 + neighborsBytes.length + 1 + routeBytes.length + dataBytes.length + IDENTITY.SIGNATURE_LENGTH;
108
+ const buffer = new ArrayBuffer(totalBytes);
109
+ const bufferView = new Uint8Array(buffer);
110
+ this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dataBytes, this.publicKey);
111
+
112
+ const NDBL = neighborsBytes.length + dataBytes.length;
113
+ bufferView.set(neighborsBytes, 47); // X bytes for neighbors
114
+ bufferView.set(dataBytes, 47 + neighborsBytes.length); // X bytes for data
115
+ bufferView.set([route.length], 47 + NDBL); // 1 byte for route length
116
+ bufferView.set(routeBytes, 47 + 1 + NDBL); // X bytes for route
117
+
118
+ this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH);
119
+ return bufferView;
120
+ }
121
+ /** @param {Uint8Array} serialized @param {string[]} newRoute */
122
+ createReroutedUnicastMessage(serialized, newRoute) {
123
+ if (newRoute.length < 2) throw new Error('Failed to create rerouted unicast message: route must have at least 2 nodes (next hop and target).');
124
+ if (newRoute.length > UNICAST.MAX_HOPS) throw new Error(`Failed to create rerouted unicast message: route exceeds max hops (${UNICAST.MAX_HOPS}).`);
125
+
126
+ const routeBytesArray = newRoute.map(id => this.converter.stringToBytes(id));
127
+ const totalBytes = serialized.length + 32 + (IDENTITY.ID_LENGTH * routeBytesArray.length) + IDENTITY.SIGNATURE_LENGTH;
128
+ const buffer = new ArrayBuffer(totalBytes);
129
+ const bufferView = new Uint8Array(buffer);
130
+ bufferView.set(serialized, 0); // original serialized message
131
+ bufferView.set(this.publicKey, serialized.length); // 32 bytes for new public key
132
+ for (let i = 0; i < routeBytesArray.length; i++) bufferView.set(routeBytesArray[i], serialized.length + 32 + (i * IDENTITY.ID_LENGTH)); // new route
133
+
134
+ this.signBufferViewAndAppendSignature(bufferView, this.privateKey, totalBytes - IDENTITY.SIGNATURE_LENGTH);
135
+ return bufferView;
136
+ }
137
+ /** @param {string[]} ids */
138
+ #idsToBytes(ids) {
139
+ if (IDENTITY.ARE_IDS_HEX) return this.converter.hexToBytes(ids.join(''), IDENTITY.ID_LENGTH * ids.length);
140
+ return this.converter.stringToBytes(ids.join(''));
141
+ }
142
+ /** @param {string | Uint8Array | Object} data */
143
+ #dataToBytes(data) { // typeCodes: 1=string, 2=Uint8Array, 3=JSON
144
+ if (typeof data === 'string') return { dataCode: 1, dataBytes: this.converter.stringToBytes(data) };
145
+ if (data instanceof Uint8Array) return { dataCode: 2, dataBytes: data };
146
+ return { dataCode: 3, dataBytes: this.converter.stringToBytes(JSON.stringify(data)) };
147
+ }
148
+ /** @param {Uint8Array} bufferView @param {number} marker @param {number} dataCode @param {number} neighborsCount @param {number} timestamp @param {Uint8Array} dataBytes @param {Uint8Array} publicKey */
149
+ #setBufferHeader(bufferView, marker, dataCode, neighborsCount, timestamp, dataBytes, publicKey) {
150
+ const timestampBytes = this.converter.numberTo8Bytes(timestamp);
151
+ const dataLengthBytes = this.converter.numberTo4Bytes(dataBytes.length);
152
+ bufferView.set([marker], 0); // 1 byte for marker
153
+ bufferView.set([dataCode], 1); // 1 byte for data type code
154
+ bufferView.set([neighborsCount], 2); // 1 byte for neighbors length
155
+ bufferView.set(timestampBytes, 3); // 8 bytes for timestamp
156
+ bufferView.set(dataLengthBytes, 11); // 4 bytes for data length
157
+ bufferView.set(publicKey, 15); // 32 bytes for pubkey
158
+ }
159
+
160
+ // MESSSAGE READING (DESERIALIZATION AND SIGNATURE VERIFICATION INCLUDED)
161
+ /** @param {Uint8Array} publicKey @param {Uint8Array} dataToVerify @param {Uint8Array} signature */
162
+ verifySignature(publicKey, dataToVerify, signature) {
163
+ if (this.AVOID_CRYPTO) return true;
164
+ return ed25519.verify(dataToVerify, signature, publicKey);
165
+ }
166
+ /** @param {Uint8Array} bufferView */
167
+ readBufferHeader(bufferView, readAssociatedId = true) {
168
+ const marker = bufferView[0]; // 1 byte for marker
169
+ const dataCode = bufferView[1]; // 1 byte for data type code
170
+ const neighborsCount = bufferView[2]; // 1 byte for neighbors length
171
+ const tBytes = bufferView.slice(3, 11); // 8 bytes for timestamp
172
+ const lBytes = bufferView.slice(11, 15); // 4 bytes for data length
173
+ const pubkey = bufferView.slice(15, 47); // 32 bytes for pubkey
174
+ const associatedId = readAssociatedId ? this.#idFromPublicKey(pubkey) : null;
175
+ const neighLength = neighborsCount * this.idLength;
176
+ const timestamp = this.converter.bytes8ToNumber(tBytes);
177
+ const dataLength = this.converter.bytes4ToNumber(lBytes);
178
+ return { marker, dataCode, neighLength, timestamp, dataLength, pubkey, associatedId };
179
+ }
180
+ /** @param {Uint8Array | ArrayBuffer} serialized @return {GossipMessage | null } */
181
+ readGossipMessage(serialized) {
182
+ if (this.verbose > 3) console.log(`%creadGossipMessage ${serialized.byteLength} bytes`, LOG_CSS.CRYPTO_CODEX);
183
+ if (this.verbose > 3) console.log(`%c${serialized}`, LOG_CSS.CRYPTO_CODEX);
184
+ try { // 1, 1, 1, 8, 4, 32, X, 64, 1
185
+ const { marker, dataCode, neighLength, timestamp, dataLength, pubkey, associatedId } = this.readBufferHeader(serialized);
186
+ const topic = GOSSIP.MARKERS_BYTES[marker];
187
+ if (topic === undefined) throw new Error(`Failed to deserialize gossip message: unknown marker byte ${d[0]}.`);
188
+ const NDBL = neighLength + dataLength;
189
+ const neighbors = this.#bytesToIds(serialized.slice(47, 47 + neighLength));
190
+ const deserializedData = this.#bytesToData(dataCode, serialized.slice(47 + neighLength, 47 + NDBL));
191
+ const signatureStart = 47 + NDBL;
192
+ const signature = serialized.slice(signatureStart, signatureStart + IDENTITY.SIGNATURE_LENGTH);
193
+ const HOPS = serialized[serialized.length - 1];
194
+ const expectedEnd = signatureStart + IDENTITY.SIGNATURE_LENGTH + 1;
195
+ const senderId = associatedId;
196
+ return new GossipMessage(topic, timestamp, neighbors, HOPS, senderId, pubkey, deserializedData, signature, signatureStart, expectedEnd);
197
+ } catch (error) { if (this.verbose > 1) console.warn(`Error deserializing ${topic || 'unknown'} gossip message:`, error.stack); }
198
+ return null;
199
+ }
200
+ /** @param {Uint8Array | ArrayBuffer} serialized @return {DirectMessage | ReroutedDirectMessage | null} */
201
+ readUnicastMessage(serialized) {
202
+ if (this.verbose > 3) console.log(`%creadUnicastMessage ${serialized.byteLength} bytes`, LOG_CSS.CRYPTO_CODEX);
203
+ if (this.verbose > 3) console.log(`%c${serialized}`, LOG_CSS.CRYPTO_CODEX);
204
+ try { // 1, 1, 1, 8, 4, 32, X, 1, X, 64
205
+ const { marker, dataCode, neighLength, timestamp, dataLength, pubkey } = this.readBufferHeader(serialized, false);
206
+ const type = UNICAST.MARKERS_BYTES[marker];
207
+ if (type === undefined) throw new Error(`Failed to deserialize unicast message: unknown marker byte ${d[0]}.`);
208
+ const NDBL = neighLength + dataLength;
209
+ const neighbors = this.#bytesToIds(serialized.slice(47, 47 + neighLength));
210
+ const deserializedData = this.#bytesToData(dataCode, serialized.slice(47 + neighLength, 47 + NDBL));
211
+ const routeLength = serialized[47 + NDBL];
212
+ const routeBytesLength = routeLength * this.idLength;
213
+ const signatureStart = 47 + NDBL + 1 + routeBytesLength;
214
+ const routeBytes = serialized.slice(47 + NDBL + 1, signatureStart);
215
+ const route = this.#bytesToIds(routeBytes);
216
+ const initialMessageEnd = signatureStart + IDENTITY.SIGNATURE_LENGTH;
217
+ const signature = serialized.slice(signatureStart, initialMessageEnd);
218
+ const isPatched = (serialized.length > initialMessageEnd);
219
+
220
+ if (!isPatched) return new DirectMessage(type, timestamp, neighbors, route, pubkey, deserializedData, signature, signatureStart, initialMessageEnd);
221
+
222
+ const rerouterPubkey = serialized.slice(initialMessageEnd, initialMessageEnd + 32);
223
+ const newRoute = this.#bytesToIds(serialized.slice(initialMessageEnd + 32, serialized.length - IDENTITY.SIGNATURE_LENGTH));
224
+ const rerouterSignature = serialized.slice(serialized.length - IDENTITY.SIGNATURE_LENGTH);
225
+ return new ReroutedDirectMessage(type, timestamp, neighbors, route, pubkey, deserializedData, signature, rerouterPubkey, newRoute, rerouterSignature, serialized.length);
226
+ } catch (error) { if (this.verbose > 1) console.warn(`Error deserializing ${type || 'unknown'} unicast message:`, error.stack); }
227
+ return null;
228
+ }
229
+ /** @param {Uint8Array} serialized */
230
+ #bytesToIds(serialized) {
231
+ const ids = [];
232
+ const idLength = this.idLength;
233
+ if (serialized.length % idLength !== 0) throw new Error('Failed to parse ids: invalid serialized length.');
234
+
235
+ for (let i = 0; i < serialized.length / idLength; i++) {
236
+ const idBytes = serialized.slice(i * idLength, (i + 1) * idLength);
237
+ if (IDENTITY.ARE_IDS_HEX) ids.push(this.converter.bytesToHex(idBytes, IDENTITY.ID_LENGTH));
238
+ else ids.push(this.converter.bytesToString(idBytes));
239
+ }
240
+ return ids;
241
+ }
242
+ /** @param {1 | 2 | 3} dataCode @param {Uint8Array} dataBytes @return {string | Uint8Array | Object} */
243
+ #bytesToData(dataCode, dataBytes) {
244
+ if (dataCode === 1) return this.converter.bytesToString(dataBytes);
245
+ if (dataCode === 2) return dataBytes;
246
+ if (dataCode === 3) return JSON.parse(this.converter.bytesToString(dataBytes));
247
+ throw new Error(`Failed to parse data: unknown data code '${dataCode}'.`);
248
+ }
249
+ }
@@ -0,0 +1,159 @@
1
+ import { CLOCK, GOSSIP, DISCOVERY } from './parameters.mjs';
2
+ import { xxHash32 } from '../libs/xxhash32.mjs';
3
+
4
+ export class GossipMessage { // TYPE DEFINITION
5
+ topic = 'gossip';
6
+ timestamp;
7
+ neighborsList;
8
+ HOPS;
9
+ senderId;
10
+ pubkey;
11
+ data;
12
+ signature;
13
+ signatureStart; // position in the serialized message where the signature starts
14
+ expectedEnd; // expected length of the serialized message
15
+
16
+ /** @param {string} topic @param {number} timestamp @param {string[]} neighborsList @param {number} HOPS @param {string} senderId @param {string} pubkey @param {string | Uint8Array | Object} data @param {string | undefined} signature @param {number} signatureStart @param {number} expectedEnd */
17
+ constructor(topic, timestamp, neighborsList, HOPS, senderId, pubkey, data, signature, signatureStart, expectedEnd) {
18
+ this.topic = topic; this.timestamp = timestamp; this.neighborsList = neighborsList;
19
+ this.HOPS = HOPS; this.senderId = senderId; this.pubkey = pubkey; this.data = data;
20
+ this.signature = signature; this.signatureStart = signatureStart; this.expectedEnd = expectedEnd;
21
+ }
22
+ }
23
+
24
+ /** - 'BloomFilterCacheEntry' Definition
25
+ * @typedef {Object} BloomFilterCacheEntry
26
+ * @property {string} hash
27
+ * @property {string} senderId
28
+ * @property {string} topic
29
+ * @property {Uint8Array} serializedMessage
30
+ * @property {number} expiration
31
+ */
32
+ class DegenerateBloomFilter {
33
+ cryptoCodex;
34
+ xxHash32UsageCount = 0;
35
+ /** @type {Record<string, number>} */
36
+ seenTimeouts = {}; // Map of message hashes to their expiration timestamps
37
+
38
+ /** @type {BloomFilterCacheEntry[]} */ cache = [];
39
+ cleanupFrequency = 100;
40
+ cleanupIn = 100;
41
+ cleanupDurationWarning = 10;
42
+
43
+ /** @param {import('./crypto-codex.mjs').CryptoCodex} cryptoCodex */
44
+ constructor(cryptoCodex) { this.cryptoCodex = cryptoCodex; }
45
+
46
+ // PUBLIC API
47
+ /** @param {'asc' | 'desc'} order */
48
+ getGossipHistoryByTime(order = 'asc') {
49
+ const lightenHistory = this.cache.map(e => ({ senderId: e.senderId, topic: e.topic, data: e.serializedMessage }));
50
+ return order === 'asc' ? lightenHistory : lightenHistory.reverse();
51
+ }
52
+ /** @param {Uint8Array} serializedMessage */
53
+ addMessage(serializedMessage) {
54
+ const n = CLOCK.time;
55
+ const { marker, neighLength, timestamp, dataLength, pubkey, associatedId } = this.cryptoCodex.readBufferHeader(serializedMessage);
56
+ if (n - timestamp > GOSSIP.EXPIRATION) return;
57
+
58
+ const hashableData = serializedMessage.subarray(0, 47 + neighLength + dataLength);
59
+ const h = xxHash32(hashableData);
60
+ this.xxHash32UsageCount++;
61
+ if (this.seenTimeouts[h]) return;
62
+
63
+ const topic = GOSSIP.MARKERS_BYTES[marker];
64
+ const senderId = associatedId;
65
+ const expiration = n + GOSSIP.CACHE_DURATION;
66
+ this.cache.push({ hash: h, senderId, topic, serializedMessage, timestamp, expiration });
67
+ this.seenTimeouts[h] = expiration;
68
+
69
+ if (--this.cleanupIn <= 0) this.#cleanupOldestEntries(n);
70
+ return { hash: h, isNew: !this.seenTimeouts[h] };
71
+ }
72
+ #cleanupOldestEntries(n = CLOCK.time) {
73
+ let firstValidIndex = -1;
74
+ for (let i = 0; i < this.cache.length; i++)
75
+ if (this.cache[i].expiration <= n) delete this.seenTimeouts[this.cache[i].hash];
76
+ else if (firstValidIndex === -1) firstValidIndex = i;
77
+
78
+ if (firstValidIndex > 0) this.cache = this.cache.slice(firstValidIndex);
79
+ else if (firstValidIndex === -1) this.cache = [];
80
+ this.cleanupIn = this.cleanupFrequency;
81
+ }
82
+ }
83
+
84
+ export class Gossip {
85
+ /** @type {Record<string, Function[]>} */ callbacks = { message_handle: [] };
86
+ id; cryptoCodex; arbiter; peerStore; verbose; bloomFilter;
87
+
88
+ /** @param {string} selfId @param {import('./crypto-codex.mjs').CryptoCodex} cryptoCodex @param {import('./arbiter.mjs').Arbiter} arbiter @param {import('./peer-store.mjs').PeerStore} peerStore */
89
+ constructor(selfId, cryptoCodex, arbiter, peerStore, verbose = 0) {
90
+ this.id = selfId;
91
+ this.cryptoCodex = cryptoCodex;
92
+ this.arbiter = arbiter;
93
+ this.peerStore = peerStore;
94
+ this.verbose = verbose;
95
+ this.bloomFilter = new DegenerateBloomFilter(cryptoCodex);
96
+ }
97
+
98
+ /** @param {string} callbackType @param {Function} callback */
99
+ on(callbackType, callback) {
100
+ if (!this.callbacks[callbackType]) this.callbacks[callbackType] = [callback];
101
+ else this.callbacks[callbackType].push(callback);
102
+ }
103
+ /** Gossip a message to all connected peers > will be forwarded to all peers
104
+ * @param {string | Uint8Array | Object} data @param {string} topic @param {number} [HOPS] */
105
+ broadcastToAll(data, topic = 'gossip', HOPS) {
106
+ const hops = HOPS || GOSSIP.HOPS[topic] || GOSSIP.HOPS.default;
107
+ const serializedMessage = this.cryptoCodex.createGossipMessage(topic, data, hops, this.peerStore.neighborsList);
108
+ if (!this.bloomFilter.addMessage(serializedMessage)) return; // avoid sending duplicate messages
109
+ if (this.verbose > 3) console.log(`(${this.id}) Gossip ${topic}, to ${JSON.stringify(this.peerStore.neighborsList)}: ${data}`);
110
+ for (const peerId of this.peerStore.neighborsList) this.#broadcastToPeer(peerId, serializedMessage);
111
+ }
112
+ /** @param {string} targetId @param {any} serializedMessage */
113
+ #broadcastToPeer(targetId, serializedMessage) {
114
+ if (targetId === this.id) throw new Error(`Refusing to send a gossip message to self (${this.id}).`);
115
+ const transportInstance = this.peerStore.connected[targetId]?.transportInstance;
116
+ if (!transportInstance) return { success: false, reason: `Transport instance is not available for peer ${targetId}.` };
117
+ try { transportInstance.send(serializedMessage); }
118
+ catch (error) { this.peerStore.connected[targetId]?.close(); }
119
+ }
120
+ sendGossipHistoryToPeer(peerId) {
121
+ const gossipHistory = this.bloomFilter.getGossipHistoryByTime('asc');
122
+ for (const entry of gossipHistory) this.#broadcastToPeer(peerId, entry.data);
123
+ }
124
+ /** @param {string} from @param {Uint8Array} serialized @returns {void} */
125
+ async handleGossipMessage(from, serialized) {
126
+ if (this.arbiter.isBanished(from)) return this.verbose >= 3 ? console.info(`%cReceived gossip message from banned peer ${from}, ignoring.`, 'color: red;') : null;
127
+ if (!this.arbiter.countMessageBytes(from, serialized.byteLength, 'gossip')) return; // ignore if flooding/banished
128
+ for (const cb of this.callbacks.message_handle || []) cb(serialized); // Simulator counter before filtering
129
+ if (!this.bloomFilter.addMessage(serialized)) return; // already processed this message
130
+
131
+ const message = this.cryptoCodex.readGossipMessage(serialized);
132
+ if (!message) return this.arbiter.countPeerAction(from, 'WRONG_SERIALIZATION');
133
+ await this.arbiter.digestMessage(from, message, serialized);
134
+ if (this.arbiter.isBanished(from)) return; // ignore messages from banished peers
135
+ if (this.arbiter.isBanished(message.senderId)) return; // ignore messages from banished peers
136
+
137
+ const { topic, timestamp, neighborsList, HOPS, senderId, data } = message;
138
+ if (senderId === this.id) throw new Error(`#${this.id}#${from}# Received our own message back from peer ${from}.`);
139
+
140
+ if (this.verbose > 3) // LOGGING
141
+ if (senderId === from) console.log(`(${this.id}) Gossip ${topic} from ${senderId}: ${data}`);
142
+ else console.log(`(${this.id}) Gossip ${topic} from ${senderId} (by: ${from}): ${data}`);
143
+
144
+ this.peerStore.digestPeerNeighbors(senderId, neighborsList);
145
+ for (const cb of this.callbacks[topic] || []) cb(senderId, data, HOPS, message); // specific topic callback
146
+ if (HOPS < 1) return; // stop forwarding if HOPS is 0
147
+
148
+ const nCount = this.peerStore.neighborsList.length;
149
+ const trm = Math.max(1, nCount / GOSSIP.TRANSMISSION_RATE.NEIGHBOURS_PONDERATION);
150
+ const tRateBase = GOSSIP.TRANSMISSION_RATE[topic] || GOSSIP.TRANSMISSION_RATE.default;
151
+ const transmissionRate = Math.pow(tRateBase, trm);
152
+ const avoidTransmissionRate = nCount < GOSSIP.TRANSMISSION_RATE.MIN_NEIGHBOURS_TO_APPLY_PONDERATION;
153
+ const serializedToTransmit = this.cryptoCodex.decrementGossipHops(serialized);
154
+ for (const peerId of this.peerStore.neighborsList)
155
+ if (peerId === from) continue; // avoid sending back to sender
156
+ else if (!avoidTransmissionRate && Math.random() > transmissionRate) continue; // apply gossip transmission rate
157
+ else this.#broadcastToPeer(peerId, serializedToTransmit);
158
+ }
159
+ }
@@ -0,0 +1,175 @@
1
+ import { CLOCK, NODE, TRANSPORTS, LOG_CSS } from './parameters.mjs';
2
+ import { xxHash32 } from '../libs/xxhash32.mjs';
3
+ import wrtc from 'wrtc';
4
+
5
+ /** - 'OfferObj' Definition
6
+ * @typedef {Object} OfferObj
7
+ * @property {number} timestamp
8
+ * @property {boolean} isUsed // => if true => should be deleted
9
+ * @property {number} sentCounter
10
+ * @property {Object} signal
11
+ * @property {import('simple-peer').Instance} offererInstance
12
+ * @property {boolean} isDigestingOneAnswer Flag to avoid multiple answers handling at the same time (DISCOVERY.LOOP_DELAY (2.5s) will be doubled (5s) between two answers handling)
13
+ * @property {Array<{peerId: string, signal: any, timestamp: number, used: boolean}>} answers
14
+ * @property {Record<string, boolean>} answerers key: peerId, value: true */
15
+
16
+ export class OfferManager { // Manages the creation of SDP offers and handling of answers
17
+ id;
18
+ verbose;
19
+ stunUrls;
20
+
21
+ /** @param {string} id @param {Array<{urls: string}>} stunUrls */
22
+ constructor(id, stunUrls, verbose = 0) { this.id = id; this.verbose = verbose; this.stunUrls = stunUrls; }
23
+
24
+ onSignalAnswer = null; // function(remoteId, signalData, offerHash)
25
+ onConnect = null; // function(remoteId, transportInstance)
26
+
27
+ /** @type {Record<number, import('simple-peer').Instance>} key: expiration timestamp */
28
+ offerInstanceByExpiration = {};
29
+ creatingOffer = false; // flag to avoid multiple simultaneous creations (shared between all offers)
30
+ offerCreationTimeout = null; // sequential creation timeout (shared between all offers)
31
+ offersToCreate = TRANSPORTS.MAX_SDP_OFFERS;
32
+ /** @type {Record<string, OfferObj>} key: offerHash **/ offers = {};
33
+
34
+ tick() { // called in peerStore to avoid multiple intervals
35
+ const now = CLOCK.time;
36
+ // CLEAR EXPIRED CREATOR OFFER INSTANCES
37
+ for (const expiration in this.offerInstanceByExpiration) {
38
+ const instance = this.offerInstanceByExpiration[expiration];
39
+ if (now < expiration) continue; // not expired yet
40
+ instance?.destroy();
41
+ delete this.offerInstanceByExpiration[expiration];
42
+ this.creatingOffer = false; // release flag
43
+ }
44
+
45
+ // CLEAR USED AND EXPIRED OFFERS
46
+ for (const hash in this.offers) {
47
+ const offer = this.offers[hash];
48
+ if (offer.offererInstance.destroyed) { delete this.offers[hash]; continue; } // offerer destroyed
49
+ if (offer.isUsed) { delete this.offers[hash]; continue; } // used offer => remove it (handled by peerStore)
50
+ if (offer.timestamp + TRANSPORTS.SDP_OFFER_EXPIRATION > now) continue; // not expired yet
51
+ offer.offererInstance?.destroy();
52
+ delete this.offers[hash];
53
+ }
54
+
55
+ // TRY TO USE AVAILABLE ANSWERS
56
+ let offerCount = 0;
57
+ for (const hash in this.offers) {
58
+ offerCount++; // [live at the first line of the loop] used just below -> avoid Object.keys() call
59
+ const offer = this.offers[hash];
60
+ if (offer.isDigestingOneAnswer) { offer.isDigestingOneAnswer = false; continue; }
61
+ if (offer.offererInstance.destroyed) continue; // offerer destroyed
62
+
63
+ const unusedAnswers = offer.answers.filter(a => !a.used);
64
+ if (!unusedAnswers.length) continue; // no answers available
65
+
66
+ const newestAnswer = unusedAnswers.reduce((a, b) => a.timestamp > b.timestamp ? a : b);
67
+ if (!newestAnswer) continue; // all answers are used
68
+
69
+ newestAnswer.used = true;
70
+ const receivedSince = now - newestAnswer.timestamp;
71
+ if (receivedSince > NODE.CONNECTION_UPGRADE_TIMEOUT / 2) continue; // remote peer will break the connection soon, don't use this answer
72
+ offer.offererInstance.signal(newestAnswer.signal);
73
+ offer.isDigestingOneAnswer = true;
74
+ if (this.verbose > 2) console.log(`(${this.id}) Using answer from ${newestAnswer.peerId} for offer ${hash} (received since ${receivedSince} ms)`);
75
+ }
76
+
77
+ if (this.creatingOffer) return; // already creating one or unable to send
78
+ if (offerCount >= this.offersToCreate) return; // already have enough offers
79
+
80
+ // CREATE NEW OFFER
81
+ this.creatingOffer = true;
82
+ const expiration = now + (TRANSPORTS.SIGNAL_CREATION_TIMEOUT || 8_000);
83
+ const instance = this.#createOffererInstance(expiration);
84
+ this.offerInstanceByExpiration[expiration] = instance;
85
+ };
86
+ #createOffererInstance(expiration) {
87
+ const instance = new TRANSPORTS.PEER({ initiator: true, trickle: false, wrtc, config: { iceServers: this.stunUrls } });
88
+ instance.on('error', error => this.#onError(error));
89
+ instance.on('signal', data => { // trickle: false => only one signal event with the full offer
90
+ const { candidate, type } = data; // with trickle, we need to adapt the approach.
91
+ if (!data || candidate) throw new Error('Unexpected signal data from offerer instance: ' + JSON.stringify(data));
92
+ if (type !== 'offer') throw new Error('Unexpected signal type from offerer instance: ' + type);
93
+
94
+ // OFFER READY
95
+ delete this.offerInstanceByExpiration[expiration];
96
+ const offerHash = xxHash32(JSON.stringify(data)); // UN PEU BLOQUE ICI (connect on voudrait identifer le peer)
97
+ instance.on('connect', () => { // cb > peerStore > Node > Node.#onConnect()
98
+ if (this.offers[offerHash]) this.offers[offerHash].isUsed = true;
99
+ this.onConnect(undefined, instance);
100
+ });
101
+ this.offers[offerHash] = { timestamp: CLOCK.time, sentCounter: 0, signal: data, offererInstance: instance, answers: [], answerers: {}, isUsed: false };
102
+ this.creatingOffer = false; // release flag
103
+ });
104
+
105
+ return instance;
106
+ }
107
+ /** @param {Error} error @param {string} incl @param {number} level @param {'includes' | 'startsWith'} searchMode (Prefer 'startsWith' for performance) */
108
+ #logAndOrIgnore(error, incl = '', level = 2, searchMode = 'includes') { // if false => log it fully, if true => ignore it (message logged or ignored based on level)
109
+ if (searchMode[0] === 'i' && !error.message.includes(incl)) return false;
110
+ else if (!error.message.startsWith(incl)) return false;
111
+ if (this.verbose >= level) console.info(`%cOfferManager => ${error.message}`, LOG_CSS.PEER_STORE);
112
+ return true;
113
+ }
114
+ #onError = (error) => {
115
+ if (this.verbose < 1) return; // avoid logging
116
+ // PRODUCTION (SimplePeer ERRORS) --|
117
+ if (this.#logAndOrIgnore(error, 'Ice connection failed', 2)) return;
118
+ if (this.#logAndOrIgnore(error, 'Connection failed', 2)) return;
119
+ // --PRODUCTION ----------------- --|
120
+
121
+ if (this.#logAndOrIgnore(error, 'Remote transport instance', 3, 'startsWith')) return;
122
+ if (this.#logAndOrIgnore(error, 'Simulated failure', 4, 'startsWith')) return;
123
+ if (this.#logAndOrIgnore(error, 'No peer found', 4, 'startsWith')) return;
124
+ if (this.#logAndOrIgnore(error, 'Missing transport instance', 2, 'startsWith')) return;
125
+ if (this.#logAndOrIgnore(error, 'Failed to create answer', 2, 'startsWith')) return;
126
+ if (this.#logAndOrIgnore(error, 'Transport instance', 3, 'startsWith')) return;
127
+ if (this.#logAndOrIgnore(error, 'cannot signal after peer is destroyed', 3, 'startsWith')) return;
128
+ if (this.#logAndOrIgnore(error, 'No pending', 3)) return;
129
+ if (this.#logAndOrIgnore(error, 'is already linked', 3)) return;
130
+ if (this.#logAndOrIgnore(error, 'There is already a pending', 3)) return;
131
+ if (this.#logAndOrIgnore(error, 'closed the connection', 3)) return;
132
+ if (this.#logAndOrIgnore(error, 'No transport instance found for id:', 3)) return;
133
+
134
+ if (this.verbose > 0) console.error(`transportInstance ERROR => `, error.stack);
135
+ };
136
+ /** @param {string} remoteId @param {{type: 'answer', sdp: Record<string, string>}} signal @param {string} offerHash @param {number} timestamp receptionTimestamp */
137
+ addSignalAnswer(remoteId, signal, offerHash, timestamp) {
138
+ if (!signal || signal.type !== 'answer' || !offerHash) return; // ignore non-answers or missing offerHash
139
+ if (!this.offers[offerHash] || this.offers[offerHash].answerers[remoteId]) return; // already have an answer from this peerId
140
+ this.offers[offerHash].answerers[remoteId] = true; // mark as having answered - one answer per peerId
141
+ this.offers[offerHash].answers.push({ peerId: remoteId, signal, timestamp });
142
+ if (this.verbose > 3) console.log(`(OfferManager) Added answer from ${remoteId} for offer ${offerHash}`);
143
+ }
144
+ /** @param {string} remoteId @param {{type: 'offer' | 'answer', sdp: Record<string, string>}} signal @param {string} [offerHash] offer only */
145
+ getTransportInstanceForSignal(remoteId, signal, offerHash) {
146
+ try {
147
+ if (!signal || !signal.type || !signal.sdp) throw new Error('Wrong remote SDP provided');
148
+
149
+ const { type, sdp } = signal;
150
+ if (type !== 'offer' && type !== 'answer') throw new Error('Invalid remote SDP type');
151
+ if (type === 'offer' && !sdp) throw new Error('No SDP in the remote SDP offer');
152
+ if (type === 'answer' && !sdp) throw new Error('No SDP in the remote SDP answer');
153
+
154
+ if (type === 'answer') { // NEED TO FIND THE PENDING OFFERER INSTANCE
155
+ const instance = offerHash ? this.offers[offerHash]?.offererInstance : null;
156
+ if (!instance) throw new Error('No pending offer found for the given offer hash to accept the answer');
157
+ return instance;
158
+ }
159
+
160
+ // type === 'offer' => CREATE ANSWERER INSTANCE
161
+ const instance = new TRANSPORTS.PEER({ initiator: false, trickle: false, wrtc, config: { iceServers: this.stunUrls } });
162
+ instance.on('error', (error) => this.#onError(error));
163
+ instance.on('signal', (data) => this.onSignalAnswer(remoteId, data, offerHash));
164
+ instance.on('connect', () => this.onConnect(remoteId, instance));
165
+ return instance;
166
+ } catch (error) {
167
+ if (error.message.startsWith('No pending offer found') && this.verbose < 2) return null; // avoid logging
168
+ if (this.verbose > 1 && error.message.startsWith('No pending offer found')) return console.info(`%c${error.message}`, LOG_CSS.PEER_STORE);
169
+ if (this.verbose > 0) console.error(error.stack);
170
+ }
171
+ }
172
+ destroy() {
173
+ for (const offerHash in this.offers) this.offers[offerHash].offererInstance?.destroy();
174
+ }
175
+ }