@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 +3 -3
- package/core/config.mjs +4 -0
- package/core/crypto-codex.mjs +57 -67
- package/core/gossip.mjs +2 -2
- package/core/node-services.mjs +4 -5
- package/core/node.mjs +77 -12
- package/core/peer-store.mjs +8 -0
- package/core/topologist.mjs +6 -6
- package/core/unicast.mjs +9 -15
- package/hive-p2p.min.js +17 -5
- package/index.mjs +3 -3
- package/package.json +3 -2
- package/services/cryptos.mjs +4 -12
- package/libs/build-ed25519-custom.js +0 -4
- package/libs/ed25519-custom.min.js +0 -9
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 (!
|
|
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
|
-
|
|
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 =
|
|
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
package/core/crypto-codex.mjs
CHANGED
|
@@ -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 } =
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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,
|
|
151
|
+
this.#setBufferHeader(bufferView, MARKER, dataCode, neighbors.length, timestamp, dBytes, this.publicKey);
|
|
169
152
|
|
|
170
|
-
const NDBL = neighborsBytes.length +
|
|
153
|
+
const NDBL = neighborsBytes.length + dBytes.length;
|
|
171
154
|
bufferView.set(neighborsBytes, 47); // X bytes for neighbors
|
|
172
|
-
bufferView.set(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
verifySignature(publicKey, dataToVerify, signature) {
|
|
221
204
|
if (this.AVOID_CRYPTO) return true;
|
|
222
|
-
return
|
|
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)
|
|
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
|
-
|
|
106
|
+
broadcastToAll(data, topic = 'gossip', HOPS) {
|
|
107
107
|
const hops = HOPS || GOSSIP.HOPS[topic] || GOSSIP.HOPS.default;
|
|
108
|
-
const serializedMessage =
|
|
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);
|
package/core/node-services.mjs
CHANGED
|
@@ -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',
|
|
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',
|
|
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 (!
|
|
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
|
-
|
|
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 ||
|
|
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}
|
|
164
|
-
* @param {number} [timestamp] default: CLOCK.time
|
|
165
|
-
|
|
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
|
-
/**
|
|
168
|
-
|
|
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'
|
|
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
|
|
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) */
|
package/core/peer-store.mjs
CHANGED
|
@@ -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)],
|
package/core/topologist.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
201
|
-
ws.send(
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 =
|
|
152
|
+
const patchedMessage = this.cryptoCodex.createReroutedUnicastMessage(serialized, newRoute);
|
|
159
153
|
const nextPeerId = newRoute[selfPosition + 1];
|
|
160
154
|
this.#sendMessageToPeer(nextPeerId, patchedMessage);
|
|
161
155
|
}
|