react-native-ble-mesh 1.1.1 → 2.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.
- package/README.md +288 -172
- package/docs/IOS-BACKGROUND-BLE.md +231 -0
- package/docs/OPTIMIZATION.md +70 -0
- package/docs/SPEC-v2.1.md +308 -0
- package/package.json +1 -1
- package/src/MeshNetwork.js +659 -465
- package/src/constants/index.js +1 -0
- package/src/crypto/AutoCrypto.js +79 -0
- package/src/crypto/CryptoProvider.js +99 -0
- package/src/crypto/index.js +15 -63
- package/src/crypto/providers/ExpoCryptoProvider.js +125 -0
- package/src/crypto/providers/QuickCryptoProvider.js +134 -0
- package/src/crypto/providers/TweetNaClProvider.js +124 -0
- package/src/crypto/providers/index.js +11 -0
- package/src/errors/MeshError.js +2 -1
- package/src/expo/withBLEMesh.js +102 -0
- package/src/hooks/useMesh.js +30 -9
- package/src/hooks/useMessages.js +2 -0
- package/src/index.js +23 -8
- package/src/mesh/dedup/DedupManager.js +36 -10
- package/src/mesh/fragment/Assembler.js +5 -0
- package/src/mesh/index.js +1 -1
- package/src/mesh/monitor/ConnectionQuality.js +408 -0
- package/src/mesh/monitor/NetworkMonitor.js +327 -316
- package/src/mesh/monitor/index.js +7 -3
- package/src/mesh/peer/PeerManager.js +6 -1
- package/src/mesh/router/MessageRouter.js +26 -15
- package/src/mesh/router/RouteTable.js +7 -1
- package/src/mesh/store/StoreAndForwardManager.js +295 -297
- package/src/mesh/store/index.js +1 -1
- package/src/service/BatteryOptimizer.js +282 -278
- package/src/service/EmergencyManager.js +224 -214
- package/src/service/HandshakeManager.js +167 -13
- package/src/service/MeshService.js +72 -6
- package/src/service/SessionManager.js +77 -2
- package/src/service/audio/AudioManager.js +8 -2
- package/src/service/file/FileAssembler.js +106 -0
- package/src/service/file/FileChunker.js +79 -0
- package/src/service/file/FileManager.js +307 -0
- package/src/service/file/FileMessage.js +122 -0
- package/src/service/file/index.js +15 -0
- package/src/service/text/broadcast/BroadcastManager.js +16 -0
- package/src/transport/BLETransport.js +131 -9
- package/src/transport/MockTransport.js +1 -1
- package/src/transport/MultiTransport.js +305 -0
- package/src/transport/WiFiDirectTransport.js +295 -0
- package/src/transport/adapters/NodeBLEAdapter.js +34 -0
- package/src/transport/adapters/RNBLEAdapter.js +56 -1
- package/src/transport/index.js +6 -0
- package/src/utils/compression.js +291 -291
- package/src/crypto/aead.js +0 -189
- package/src/crypto/chacha20.js +0 -181
- package/src/crypto/hkdf.js +0 -187
- package/src/crypto/hmac.js +0 -143
- package/src/crypto/keys/KeyManager.js +0 -271
- package/src/crypto/keys/KeyPair.js +0 -216
- package/src/crypto/keys/SecureStorage.js +0 -219
- package/src/crypto/keys/index.js +0 -32
- package/src/crypto/noise/handshake.js +0 -410
- package/src/crypto/noise/index.js +0 -27
- package/src/crypto/noise/session.js +0 -253
- package/src/crypto/noise/state.js +0 -268
- package/src/crypto/poly1305.js +0 -113
- package/src/crypto/sha256.js +0 -240
- package/src/crypto/x25519.js +0 -154
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const EventEmitter = require('../utils/EventEmitter');
|
|
9
9
|
const { HandshakeError } = require('../errors');
|
|
10
10
|
const { MESSAGE_TYPE, MESH_CONFIG, EVENTS } = require('../constants');
|
|
11
|
+
const { randomBytes, concat } = require('../utils/bytes');
|
|
11
12
|
|
|
12
13
|
const STATE = Object.freeze({
|
|
13
14
|
IDLE: 'idle', INITIATOR_WAITING: 'initiator_waiting',
|
|
@@ -51,9 +52,15 @@ class HandshakeManager extends EventEmitter {
|
|
|
51
52
|
|
|
52
53
|
async handleIncomingHandshake(peerId, type, payload, transport) {
|
|
53
54
|
try {
|
|
54
|
-
if (type === MESSAGE_TYPE.HANDSHAKE_INIT) {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
if (type === MESSAGE_TYPE.HANDSHAKE_INIT) {
|
|
56
|
+
return await this._onInit(peerId, payload, transport);
|
|
57
|
+
}
|
|
58
|
+
if (type === MESSAGE_TYPE.HANDSHAKE_RESPONSE) {
|
|
59
|
+
return await this._onResponse(peerId, payload, transport);
|
|
60
|
+
}
|
|
61
|
+
if (type === MESSAGE_TYPE.HANDSHAKE_FINAL) {
|
|
62
|
+
return await this._onFinal(peerId, payload);
|
|
63
|
+
}
|
|
57
64
|
throw HandshakeError.handshakeFailed(peerId, null, { reason: 'Unknown type' });
|
|
58
65
|
} catch (err) {
|
|
59
66
|
this._fail(peerId, err);
|
|
@@ -81,23 +88,160 @@ class HandshakeManager extends EventEmitter {
|
|
|
81
88
|
};
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
_createNoise() {
|
|
91
|
+
_createNoise(keyPair, isInitiator) {
|
|
92
|
+
// Get crypto provider from keyManager if available
|
|
93
|
+
const provider = this._keyManager.provider;
|
|
94
|
+
|
|
95
|
+
// Generate ephemeral key pair for this handshake
|
|
96
|
+
let ephemeralKeyPair;
|
|
97
|
+
if (provider && typeof provider.generateKeyPair === 'function') {
|
|
98
|
+
ephemeralKeyPair = provider.generateKeyPair();
|
|
99
|
+
} else {
|
|
100
|
+
// Fallback to random keys if no provider available
|
|
101
|
+
ephemeralKeyPair = { publicKey: randomBytes(32), secretKey: randomBytes(32) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let remoteEphemeralPublic = null;
|
|
105
|
+
let sharedSecret = null;
|
|
106
|
+
let sessionKeys = null;
|
|
107
|
+
let complete = false;
|
|
108
|
+
|
|
109
|
+
const deriveSessionKeys = (secret) => {
|
|
110
|
+
// Derive send/receive keys from shared secret
|
|
111
|
+
// Use hash to derive two different keys from the secret
|
|
112
|
+
if (provider && typeof provider.hash === 'function') {
|
|
113
|
+
const h1 = provider.hash(concat(secret, new Uint8Array([0x01])));
|
|
114
|
+
const h2 = provider.hash(concat(secret, new Uint8Array([0x02])));
|
|
115
|
+
return isInitiator
|
|
116
|
+
? { sendKey: h1, recvKey: h2 }
|
|
117
|
+
: { sendKey: h2, recvKey: h1 };
|
|
118
|
+
}
|
|
119
|
+
throw new Error('Crypto provider required for key derivation. Install tweetnacl: npm install tweetnacl');
|
|
120
|
+
};
|
|
121
|
+
|
|
85
122
|
return {
|
|
86
|
-
writeMessage1: () =>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
123
|
+
writeMessage1: () => {
|
|
124
|
+
// Initiator sends ephemeral public key
|
|
125
|
+
return ephemeralKeyPair.publicKey;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
readMessage1: (msg) => {
|
|
129
|
+
// Responder receives initiator's ephemeral public key
|
|
130
|
+
remoteEphemeralPublic = new Uint8Array(msg);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
writeMessage2: () => {
|
|
134
|
+
// Responder sends ephemeral public key and derives shared secret
|
|
135
|
+
if (provider && typeof provider.sharedSecret === 'function') {
|
|
136
|
+
sharedSecret = provider.sharedSecret(ephemeralKeyPair.secretKey, remoteEphemeralPublic);
|
|
137
|
+
} else {
|
|
138
|
+
throw new Error('Crypto provider required for secure handshake. Install tweetnacl: npm install tweetnacl');
|
|
139
|
+
}
|
|
140
|
+
sessionKeys = deriveSessionKeys(sharedSecret);
|
|
141
|
+
return ephemeralKeyPair.publicKey;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
readMessage2: (msg) => {
|
|
145
|
+
// Initiator receives responder's ephemeral public key and derives shared secret
|
|
146
|
+
remoteEphemeralPublic = new Uint8Array(msg);
|
|
147
|
+
if (provider && typeof provider.sharedSecret === 'function') {
|
|
148
|
+
sharedSecret = provider.sharedSecret(ephemeralKeyPair.secretKey, remoteEphemeralPublic);
|
|
149
|
+
} else {
|
|
150
|
+
throw new Error('Crypto provider required for secure handshake. Install tweetnacl: npm install tweetnacl');
|
|
151
|
+
}
|
|
152
|
+
sessionKeys = deriveSessionKeys(sharedSecret);
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
writeMessage3: () => {
|
|
156
|
+
// Initiator confirms handshake completion
|
|
157
|
+
complete = true;
|
|
158
|
+
return ephemeralKeyPair.publicKey;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
readMessage3: (_msg) => {
|
|
162
|
+
// Responder confirms handshake completion
|
|
163
|
+
complete = true;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
isComplete: () => complete,
|
|
167
|
+
|
|
168
|
+
getRemotePublicKey: () => remoteEphemeralPublic || new Uint8Array(32),
|
|
169
|
+
|
|
170
|
+
getSession: () => {
|
|
171
|
+
if (!sessionKeys) {
|
|
172
|
+
throw new Error('Handshake not complete');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sendKey = sessionKeys.sendKey;
|
|
176
|
+
const recvKey = sessionKeys.recvKey;
|
|
177
|
+
let sendNonce = 0;
|
|
178
|
+
let recvNonce = 0;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
encrypt: (plaintext) => {
|
|
182
|
+
if (provider && typeof provider.encrypt === 'function') {
|
|
183
|
+
// Use proper AEAD encryption with nonce
|
|
184
|
+
const nonce = new Uint8Array(24); // tweetnacl uses 24-byte nonces
|
|
185
|
+
const view = new DataView(nonce.buffer);
|
|
186
|
+
// Store counter in last 8 bytes of nonce
|
|
187
|
+
if (nonce.byteLength >= 8) {
|
|
188
|
+
view.setUint32(nonce.byteLength - 8, 0, true);
|
|
189
|
+
view.setUint32(nonce.byteLength - 4, sendNonce++, true);
|
|
190
|
+
}
|
|
191
|
+
return provider.encrypt(sendKey, nonce, plaintext);
|
|
192
|
+
}
|
|
193
|
+
throw new Error('Crypto provider required for encryption');
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
decrypt: (ciphertext) => {
|
|
197
|
+
if (provider && typeof provider.decrypt === 'function') {
|
|
198
|
+
// Use proper AEAD decryption with nonce
|
|
199
|
+
const nonce = new Uint8Array(24);
|
|
200
|
+
const view = new DataView(nonce.buffer);
|
|
201
|
+
if (nonce.byteLength >= 8) {
|
|
202
|
+
view.setUint32(nonce.byteLength - 8, 0, true);
|
|
203
|
+
view.setUint32(nonce.byteLength - 4, recvNonce++, true);
|
|
204
|
+
}
|
|
205
|
+
return provider.decrypt(recvKey, nonce, ciphertext);
|
|
206
|
+
}
|
|
207
|
+
throw new Error('Crypto provider required for encryption');
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
export: () => ({
|
|
211
|
+
sendKey: Array.from(sendKey),
|
|
212
|
+
recvKey: Array.from(recvKey),
|
|
213
|
+
sendNonce,
|
|
214
|
+
recvNonce,
|
|
215
|
+
nonceSize: 24
|
|
216
|
+
})
|
|
217
|
+
};
|
|
218
|
+
}
|
|
91
219
|
};
|
|
92
220
|
}
|
|
93
221
|
|
|
94
222
|
async _onInit(peerId, payload, transport) {
|
|
95
|
-
|
|
223
|
+
const existing = this._pending.get(peerId);
|
|
224
|
+
|
|
225
|
+
if (existing) {
|
|
226
|
+
// Tie-breaking: compare public keys, lower key becomes responder
|
|
227
|
+
const localKey = this._keyManager.getPublicKey();
|
|
228
|
+
const shouldYield = this._compareKeys(localKey, peerId) > 0;
|
|
229
|
+
|
|
230
|
+
if (shouldYield) {
|
|
231
|
+
// Cancel our outgoing handshake and accept incoming
|
|
232
|
+
this.cancelHandshake(peerId);
|
|
233
|
+
} else {
|
|
234
|
+
// Keep our outgoing, reject incoming
|
|
235
|
+
throw HandshakeError.alreadyInProgress(peerId);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
96
239
|
const hs = this._createState(peerId, false);
|
|
97
240
|
this._pending.set(peerId, hs);
|
|
98
241
|
this.emit(EVENTS.HANDSHAKE_STARTED, { peerId, role: 'responder' });
|
|
99
242
|
hs.noise.readMessage1(payload);
|
|
100
|
-
|
|
243
|
+
const msg2 = hs.noise.writeMessage2();
|
|
244
|
+
await transport.send(peerId, this._wrap(MESSAGE_TYPE.HANDSHAKE_RESPONSE, msg2));
|
|
101
245
|
hs.state = STATE.RESPONDER_WAITING;
|
|
102
246
|
hs.step = 2;
|
|
103
247
|
this.emit(EVENTS.HANDSHAKE_PROGRESS, { peerId, step: 2, role: 'responder' });
|
|
@@ -107,9 +251,12 @@ class HandshakeManager extends EventEmitter {
|
|
|
107
251
|
|
|
108
252
|
async _onResponse(peerId, payload, transport) {
|
|
109
253
|
const hs = this._pending.get(peerId);
|
|
110
|
-
if (!hs || !hs.isInitiator) {
|
|
254
|
+
if (!hs || !hs.isInitiator) {
|
|
255
|
+
throw HandshakeError.invalidState(peerId, 2);
|
|
256
|
+
}
|
|
111
257
|
hs.noise.readMessage2(payload);
|
|
112
|
-
|
|
258
|
+
const msg3 = hs.noise.writeMessage3();
|
|
259
|
+
await transport.send(peerId, this._wrap(MESSAGE_TYPE.HANDSHAKE_FINAL, msg3));
|
|
113
260
|
hs.step = 3;
|
|
114
261
|
this.emit(EVENTS.HANDSHAKE_PROGRESS, { peerId, step: 3, role: 'initiator' });
|
|
115
262
|
return this._complete(peerId, hs);
|
|
@@ -166,6 +313,13 @@ class HandshakeManager extends EventEmitter {
|
|
|
166
313
|
});
|
|
167
314
|
}
|
|
168
315
|
|
|
316
|
+
_compareKeys(localKey, remoteId) {
|
|
317
|
+
// Simple string/byte comparison for deterministic tie-breaking
|
|
318
|
+
const localStr = typeof localKey === 'string' ? localKey : Array.from(localKey).join(',');
|
|
319
|
+
const remoteStr = typeof remoteId === 'string' ? remoteId : Array.from(remoteId).join(',');
|
|
320
|
+
return localStr < remoteStr ? -1 : localStr > remoteStr ? 1 : 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
169
323
|
_wrap(type, payload) {
|
|
170
324
|
const w = new Uint8Array(1 + payload.length);
|
|
171
325
|
w[0] = type;
|
|
@@ -118,6 +118,14 @@ class MeshService extends EventEmitter {
|
|
|
118
118
|
return this._textManager.sendBroadcast(content);
|
|
119
119
|
}
|
|
120
120
|
const messageId = this._generateMessageId();
|
|
121
|
+
// Actually send through the transport
|
|
122
|
+
if (this._transport) {
|
|
123
|
+
const payload = new TextEncoder().encode(content);
|
|
124
|
+
const data = new Uint8Array(1 + payload.length);
|
|
125
|
+
data[0] = MESSAGE_TYPE.TEXT;
|
|
126
|
+
data.set(payload, 1);
|
|
127
|
+
this._transport.broadcast(data);
|
|
128
|
+
}
|
|
121
129
|
this.emit(EVENTS.BROADCAST_SENT, { messageId, content });
|
|
122
130
|
return messageId;
|
|
123
131
|
}
|
|
@@ -233,9 +241,10 @@ class MeshService extends EventEmitter {
|
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
async _sendRaw(peerId, data) {
|
|
236
|
-
if (this._transport) {
|
|
237
|
-
|
|
244
|
+
if (this._state === SERVICE_STATE.DESTROYED || !this._transport) {
|
|
245
|
+
return; // Silently ignore sends after destroy
|
|
238
246
|
}
|
|
247
|
+
await this._transport.send(peerId, data);
|
|
239
248
|
}
|
|
240
249
|
|
|
241
250
|
getStatus() {
|
|
@@ -266,10 +275,49 @@ class MeshService extends EventEmitter {
|
|
|
266
275
|
_generateMessageId() { return `msg_${Date.now()}_${++this._messageCounter}`; }
|
|
267
276
|
|
|
268
277
|
_createKeyManager() {
|
|
278
|
+
const { createProvider } = require('../crypto/AutoCrypto');
|
|
279
|
+
let provider;
|
|
280
|
+
let keyPair;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
provider = createProvider('auto');
|
|
284
|
+
keyPair = provider.generateKeyPair();
|
|
285
|
+
} catch (e) {
|
|
286
|
+
// If no crypto provider is available, return a minimal fallback
|
|
287
|
+
// that generates random keys using basic randomBytes
|
|
288
|
+
const { randomBytes } = require('../utils/bytes');
|
|
289
|
+
keyPair = { publicKey: randomBytes(32), secretKey: randomBytes(32) };
|
|
290
|
+
return {
|
|
291
|
+
getStaticKeyPair: () => keyPair,
|
|
292
|
+
getPublicKey: () => keyPair.publicKey,
|
|
293
|
+
exportIdentity: () => ({ publicKey: Array.from(keyPair.publicKey) }),
|
|
294
|
+
importIdentity: (id) => {
|
|
295
|
+
if (id && id.publicKey) {
|
|
296
|
+
keyPair.publicKey = new Uint8Array(id.publicKey);
|
|
297
|
+
}
|
|
298
|
+
if (id && id.secretKey) {
|
|
299
|
+
keyPair.secretKey = new Uint8Array(id.secretKey);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
269
305
|
return {
|
|
270
|
-
getStaticKeyPair: () =>
|
|
271
|
-
getPublicKey: () =>
|
|
272
|
-
|
|
306
|
+
getStaticKeyPair: () => keyPair,
|
|
307
|
+
getPublicKey: () => keyPair.publicKey,
|
|
308
|
+
provider,
|
|
309
|
+
exportIdentity: () => ({
|
|
310
|
+
publicKey: Array.from(keyPair.publicKey),
|
|
311
|
+
secretKey: Array.from(keyPair.secretKey)
|
|
312
|
+
}),
|
|
313
|
+
importIdentity: (id) => {
|
|
314
|
+
if (id && id.publicKey) {
|
|
315
|
+
keyPair.publicKey = new Uint8Array(id.publicKey);
|
|
316
|
+
}
|
|
317
|
+
if (id && id.secretKey) {
|
|
318
|
+
keyPair.secretKey = new Uint8Array(id.secretKey);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
273
321
|
};
|
|
274
322
|
}
|
|
275
323
|
|
|
@@ -295,7 +343,25 @@ class MeshService extends EventEmitter {
|
|
|
295
343
|
if (this._textManager) {
|
|
296
344
|
this._textManager.handleIncomingMessage(peerId, type, payload);
|
|
297
345
|
} else {
|
|
298
|
-
|
|
346
|
+
// Try to parse channel ID from payload
|
|
347
|
+
let channelId = '';
|
|
348
|
+
let content = payload;
|
|
349
|
+
try {
|
|
350
|
+
const decoded = new TextDecoder().decode(payload);
|
|
351
|
+
const parsed = JSON.parse(decoded);
|
|
352
|
+
channelId = parsed.channelId || '';
|
|
353
|
+
content = parsed.content ? new TextEncoder().encode(parsed.content) : payload;
|
|
354
|
+
} catch (e) {
|
|
355
|
+
// If not JSON, try to extract channelId as length-prefixed string
|
|
356
|
+
if (payload.length > 1) {
|
|
357
|
+
const channelIdLen = payload[0];
|
|
358
|
+
if (channelIdLen > 0 && channelIdLen < payload.length) {
|
|
359
|
+
channelId = new TextDecoder().decode(payload.subarray(1, 1 + channelIdLen));
|
|
360
|
+
content = payload.subarray(1 + channelIdLen);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this._channelManager.handleChannelMessage({ channelId, senderId: peerId, content });
|
|
299
365
|
}
|
|
300
366
|
} else if (type >= MESSAGE_TYPE.VOICE_MESSAGE_START && type <= MESSAGE_TYPE.AUDIO_STREAM_END) {
|
|
301
367
|
if (this._audioManager) {
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
const { CryptoError } = require('../errors');
|
|
9
9
|
|
|
10
|
+
const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
11
|
+
const MAX_MESSAGE_COUNT = 1000000; // 1 million messages before nonce exhaustion risk
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Manages Noise Protocol sessions for secure peer communication.
|
|
12
15
|
* @class SessionManager
|
|
@@ -39,6 +42,19 @@ class SessionManager {
|
|
|
39
42
|
encryptFor(peerId, plaintext) {
|
|
40
43
|
const entry = this._sessions.get(peerId);
|
|
41
44
|
if (!entry) { throw CryptoError.encryptionFailed({ reason: 'Session not found', peerId }); }
|
|
45
|
+
|
|
46
|
+
// Check session expiry
|
|
47
|
+
if (Date.now() - entry.createdAt > MAX_SESSION_AGE_MS) {
|
|
48
|
+
this._sessions.delete(peerId);
|
|
49
|
+
throw CryptoError.encryptionFailed({ reason: 'Session expired', peerId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check nonce exhaustion
|
|
53
|
+
if (entry.messageCount >= MAX_MESSAGE_COUNT) {
|
|
54
|
+
this._sessions.delete(peerId);
|
|
55
|
+
throw CryptoError.encryptionFailed({ reason: 'Session message limit reached, re-handshake required', peerId });
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
try {
|
|
43
59
|
const ciphertext = entry.session.encrypt(plaintext);
|
|
44
60
|
entry.lastUsedAt = Date.now();
|
|
@@ -60,7 +76,7 @@ class SessionManager {
|
|
|
60
76
|
}
|
|
61
77
|
return plaintext;
|
|
62
78
|
} catch (error) {
|
|
63
|
-
|
|
79
|
+
throw CryptoError.decryptionFailed({ reason: error.message, peerId });
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
82
|
|
|
@@ -75,8 +91,67 @@ class SessionManager {
|
|
|
75
91
|
|
|
76
92
|
importSession(peerId, state) {
|
|
77
93
|
if (!state || !state.sessionData) { throw new Error('Invalid session state'); }
|
|
94
|
+
|
|
95
|
+
const data = state.sessionData;
|
|
96
|
+
|
|
97
|
+
// If sessionData already has encrypt/decrypt methods, use it directly
|
|
98
|
+
if (typeof data.encrypt === 'function' && typeof data.decrypt === 'function') {
|
|
99
|
+
this._sessions.set(peerId, {
|
|
100
|
+
session: data,
|
|
101
|
+
createdAt: state.createdAt || Date.now(),
|
|
102
|
+
lastUsedAt: state.lastUsedAt || Date.now(),
|
|
103
|
+
messageCount: state.messageCount || 0
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Reconstruct session from exported key material
|
|
109
|
+
const sendKey = data.sendKey instanceof Uint8Array ? data.sendKey : new Uint8Array(data.sendKey);
|
|
110
|
+
const recvKey = data.recvKey instanceof Uint8Array ? data.recvKey : new Uint8Array(data.recvKey);
|
|
111
|
+
let sendNonce = data.sendNonce || 0;
|
|
112
|
+
let recvNonce = data.recvNonce || 0;
|
|
113
|
+
|
|
114
|
+
// Try to get crypto provider for real encrypt/decrypt
|
|
115
|
+
let provider = null;
|
|
116
|
+
try {
|
|
117
|
+
const { createProvider } = require('../crypto/AutoCrypto');
|
|
118
|
+
provider = createProvider('auto');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// No crypto provider available
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const session = {
|
|
124
|
+
encrypt: (plaintext) => {
|
|
125
|
+
if (provider && typeof provider.encrypt === 'function') {
|
|
126
|
+
const nonce = new Uint8Array(24);
|
|
127
|
+
const view = new DataView(nonce.buffer);
|
|
128
|
+
view.setUint32(nonce.byteLength - 8, 0, true);
|
|
129
|
+
view.setUint32(nonce.byteLength - 4, sendNonce++, true);
|
|
130
|
+
return provider.encrypt(sendKey, nonce, plaintext);
|
|
131
|
+
}
|
|
132
|
+
return plaintext;
|
|
133
|
+
},
|
|
134
|
+
decrypt: (ciphertext) => {
|
|
135
|
+
if (provider && typeof provider.decrypt === 'function') {
|
|
136
|
+
const nonce = new Uint8Array(24);
|
|
137
|
+
const view = new DataView(nonce.buffer);
|
|
138
|
+
view.setUint32(nonce.byteLength - 8, 0, true);
|
|
139
|
+
view.setUint32(nonce.byteLength - 4, recvNonce++, true);
|
|
140
|
+
return provider.decrypt(recvKey, nonce, ciphertext);
|
|
141
|
+
}
|
|
142
|
+
return ciphertext;
|
|
143
|
+
},
|
|
144
|
+
export: () => ({
|
|
145
|
+
sendKey: Array.from(sendKey),
|
|
146
|
+
recvKey: Array.from(recvKey),
|
|
147
|
+
sendNonce,
|
|
148
|
+
recvNonce,
|
|
149
|
+
nonceSize: 24
|
|
150
|
+
})
|
|
151
|
+
};
|
|
152
|
+
|
|
78
153
|
this._sessions.set(peerId, {
|
|
79
|
-
session
|
|
154
|
+
session,
|
|
80
155
|
createdAt: state.createdAt || Date.now(),
|
|
81
156
|
lastUsedAt: state.lastUsedAt || Date.now(),
|
|
82
157
|
messageCount: state.messageCount || 0
|
|
@@ -121,9 +121,15 @@ class AudioManager extends EventEmitter {
|
|
|
121
121
|
* @returns {Promise<void>}
|
|
122
122
|
*/
|
|
123
123
|
async destroy() {
|
|
124
|
-
// End all sessions
|
|
124
|
+
// End all sessions and wait for completion
|
|
125
|
+
const endPromises = [];
|
|
125
126
|
for (const [, session] of this._sessions) {
|
|
126
|
-
|
|
127
|
+
if (session && typeof session.end === 'function') {
|
|
128
|
+
endPromises.push(session.end().catch(() => {}));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (endPromises.length > 0) {
|
|
132
|
+
await Promise.all(endPromises);
|
|
127
133
|
}
|
|
128
134
|
this._sessions.clear();
|
|
129
135
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview File assembler — reassembles chunks into complete files
|
|
5
|
+
* @module service/file/FileAssembler
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reassembles file chunks into a complete file.
|
|
10
|
+
* @class FileAssembler
|
|
11
|
+
*/
|
|
12
|
+
class FileAssembler {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} transferId - Transfer ID
|
|
15
|
+
* @param {number} totalChunks - Expected total chunks
|
|
16
|
+
* @param {number} totalSize - Expected total file size
|
|
17
|
+
*/
|
|
18
|
+
constructor(transferId, totalChunks, totalSize) {
|
|
19
|
+
this._transferId = transferId;
|
|
20
|
+
this._totalChunks = totalChunks;
|
|
21
|
+
this._totalSize = totalSize;
|
|
22
|
+
/** @type {Map<number, Uint8Array>} */
|
|
23
|
+
this._chunks = new Map();
|
|
24
|
+
this._receivedBytes = 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adds a chunk
|
|
29
|
+
* @param {number} index - Chunk index
|
|
30
|
+
* @param {Uint8Array} data - Chunk data
|
|
31
|
+
* @returns {boolean} True if this was a new chunk
|
|
32
|
+
*/
|
|
33
|
+
addChunk(index, data) {
|
|
34
|
+
if (index < 0 || index >= this._totalChunks) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (this._chunks.has(index)) {
|
|
38
|
+
return false; // duplicate
|
|
39
|
+
}
|
|
40
|
+
this._chunks.set(index, data);
|
|
41
|
+
this._receivedBytes += data.length;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checks if all chunks have been received
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
isComplete() {
|
|
50
|
+
return this._chunks.size === this._totalChunks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gets progress percentage
|
|
55
|
+
* @returns {number} 0-100
|
|
56
|
+
*/
|
|
57
|
+
get progress() {
|
|
58
|
+
if (this._totalChunks === 0) { return 100; }
|
|
59
|
+
return Math.round((this._chunks.size / this._totalChunks) * 100);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets number of received chunks
|
|
64
|
+
* @returns {number}
|
|
65
|
+
*/
|
|
66
|
+
get receivedChunks() {
|
|
67
|
+
return this._chunks.size;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Assembles all chunks into a single Uint8Array
|
|
72
|
+
* @returns {Uint8Array} Complete file data
|
|
73
|
+
* @throws {Error} If not all chunks received
|
|
74
|
+
*/
|
|
75
|
+
assemble() {
|
|
76
|
+
if (!this.isComplete()) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Cannot assemble: received ${this._chunks.size}/${this._totalChunks} chunks`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = new Uint8Array(this._receivedBytes);
|
|
83
|
+
let offset = 0;
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < this._totalChunks; i++) {
|
|
86
|
+
const chunk = this._chunks.get(i);
|
|
87
|
+
result.set(chunk, offset);
|
|
88
|
+
offset += chunk.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Free chunk memory immediately after assembly
|
|
92
|
+
this._chunks.clear();
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clears all stored chunks
|
|
99
|
+
*/
|
|
100
|
+
clear() {
|
|
101
|
+
this._chunks.clear();
|
|
102
|
+
this._receivedBytes = 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = FileAssembler;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview File chunker — splits files into mesh-compatible chunks
|
|
5
|
+
* @module service/file/FileChunker
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CHUNK_SIZE = 4096; // 4KB chunks
|
|
9
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB default max
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Splits a file (Uint8Array) into chunks for mesh transfer.
|
|
13
|
+
* @class FileChunker
|
|
14
|
+
*/
|
|
15
|
+
class FileChunker {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} [options={}]
|
|
18
|
+
* @param {number} [options.chunkSize=4096] - Chunk size in bytes
|
|
19
|
+
* @param {number} [options.maxFileSize=10485760] - Max file size in bytes
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this._chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
|
|
23
|
+
this._maxFileSize = options.maxFileSize || MAX_FILE_SIZE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Splits data into chunks
|
|
28
|
+
* @param {Uint8Array} data - File data
|
|
29
|
+
* @param {string} transferId - Transfer ID
|
|
30
|
+
* @returns {Object[]} Array of chunk objects
|
|
31
|
+
* @throws {Error} If data exceeds max file size
|
|
32
|
+
*/
|
|
33
|
+
chunk(data, transferId) {
|
|
34
|
+
if (!(data instanceof Uint8Array)) {
|
|
35
|
+
throw new Error('Data must be a Uint8Array');
|
|
36
|
+
}
|
|
37
|
+
if (data.length > this._maxFileSize) {
|
|
38
|
+
throw new Error(`File size ${data.length} exceeds max ${this._maxFileSize} bytes`);
|
|
39
|
+
}
|
|
40
|
+
if (data.length === 0) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const chunks = [];
|
|
45
|
+
const totalChunks = Math.ceil(data.length / this._chunkSize);
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
48
|
+
const start = i * this._chunkSize;
|
|
49
|
+
const end = Math.min(start + this._chunkSize, data.length);
|
|
50
|
+
chunks.push({
|
|
51
|
+
transferId,
|
|
52
|
+
index: i,
|
|
53
|
+
totalChunks,
|
|
54
|
+
data: data.slice(start, end)
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return chunks;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Gets the number of chunks needed for a given data size
|
|
63
|
+
* @param {number} dataSize - Data size in bytes
|
|
64
|
+
* @returns {number} Number of chunks
|
|
65
|
+
*/
|
|
66
|
+
getChunkCount(dataSize) {
|
|
67
|
+
return Math.ceil(dataSize / this._chunkSize);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Gets the chunk size
|
|
72
|
+
* @returns {number}
|
|
73
|
+
*/
|
|
74
|
+
get chunkSize() {
|
|
75
|
+
return this._chunkSize;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = FileChunker;
|