@meshwhisper/sdk 0.1.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.
Files changed (163) hide show
  1. package/README.md +138 -0
  2. package/dist/browser/index.d.ts +4 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +19 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/chaff/index.d.ts +91 -0
  7. package/dist/chaff/index.d.ts.map +1 -0
  8. package/dist/chaff/index.js +268 -0
  9. package/dist/chaff/index.js.map +1 -0
  10. package/dist/cluster/index.d.ts +159 -0
  11. package/dist/cluster/index.d.ts.map +1 -0
  12. package/dist/cluster/index.js +393 -0
  13. package/dist/cluster/index.js.map +1 -0
  14. package/dist/compliance/index.d.ts +129 -0
  15. package/dist/compliance/index.d.ts.map +1 -0
  16. package/dist/compliance/index.js +315 -0
  17. package/dist/compliance/index.js.map +1 -0
  18. package/dist/crypto/index.d.ts +65 -0
  19. package/dist/crypto/index.d.ts.map +1 -0
  20. package/dist/crypto/index.js +146 -0
  21. package/dist/crypto/index.js.map +1 -0
  22. package/dist/group/index.d.ts +155 -0
  23. package/dist/group/index.d.ts.map +1 -0
  24. package/dist/group/index.js +560 -0
  25. package/dist/group/index.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/namespace/index.d.ts +155 -0
  31. package/dist/namespace/index.d.ts.map +1 -0
  32. package/dist/namespace/index.js +278 -0
  33. package/dist/namespace/index.js.map +1 -0
  34. package/dist/node/index.d.ts +4 -0
  35. package/dist/node/index.d.ts.map +1 -0
  36. package/dist/node/index.js +19 -0
  37. package/dist/node/index.js.map +1 -0
  38. package/dist/packet/index.d.ts +63 -0
  39. package/dist/packet/index.d.ts.map +1 -0
  40. package/dist/packet/index.js +244 -0
  41. package/dist/packet/index.js.map +1 -0
  42. package/dist/permissions/index.d.ts +107 -0
  43. package/dist/permissions/index.d.ts.map +1 -0
  44. package/dist/permissions/index.js +282 -0
  45. package/dist/permissions/index.js.map +1 -0
  46. package/dist/persistence/idb-storage.d.ts +27 -0
  47. package/dist/persistence/idb-storage.d.ts.map +1 -0
  48. package/dist/persistence/idb-storage.js +75 -0
  49. package/dist/persistence/idb-storage.js.map +1 -0
  50. package/dist/persistence/index.d.ts +4 -0
  51. package/dist/persistence/index.d.ts.map +1 -0
  52. package/dist/persistence/index.js +3 -0
  53. package/dist/persistence/index.js.map +1 -0
  54. package/dist/persistence/node-storage.d.ts +33 -0
  55. package/dist/persistence/node-storage.d.ts.map +1 -0
  56. package/dist/persistence/node-storage.js +90 -0
  57. package/dist/persistence/node-storage.js.map +1 -0
  58. package/dist/persistence/serialization.d.ts +4 -0
  59. package/dist/persistence/serialization.d.ts.map +1 -0
  60. package/dist/persistence/serialization.js +49 -0
  61. package/dist/persistence/serialization.js.map +1 -0
  62. package/dist/persistence/types.d.ts +29 -0
  63. package/dist/persistence/types.d.ts.map +1 -0
  64. package/dist/persistence/types.js +5 -0
  65. package/dist/persistence/types.js.map +1 -0
  66. package/dist/ratchet/index.d.ts +80 -0
  67. package/dist/ratchet/index.d.ts.map +1 -0
  68. package/dist/ratchet/index.js +259 -0
  69. package/dist/ratchet/index.js.map +1 -0
  70. package/dist/reciprocity/index.d.ts +109 -0
  71. package/dist/reciprocity/index.d.ts.map +1 -0
  72. package/dist/reciprocity/index.js +311 -0
  73. package/dist/reciprocity/index.js.map +1 -0
  74. package/dist/relay/index.d.ts +87 -0
  75. package/dist/relay/index.d.ts.map +1 -0
  76. package/dist/relay/index.js +286 -0
  77. package/dist/relay/index.js.map +1 -0
  78. package/dist/routing/index.d.ts +136 -0
  79. package/dist/routing/index.d.ts.map +1 -0
  80. package/dist/routing/index.js +478 -0
  81. package/dist/routing/index.js.map +1 -0
  82. package/dist/sdk/index.d.ts +322 -0
  83. package/dist/sdk/index.d.ts.map +1 -0
  84. package/dist/sdk/index.js +1530 -0
  85. package/dist/sdk/index.js.map +1 -0
  86. package/dist/sybil/index.d.ts +123 -0
  87. package/dist/sybil/index.d.ts.map +1 -0
  88. package/dist/sybil/index.js +491 -0
  89. package/dist/sybil/index.js.map +1 -0
  90. package/dist/transport/browser/index.d.ts +34 -0
  91. package/dist/transport/browser/index.d.ts.map +1 -0
  92. package/dist/transport/browser/index.js +176 -0
  93. package/dist/transport/browser/index.js.map +1 -0
  94. package/dist/transport/local/index.d.ts +57 -0
  95. package/dist/transport/local/index.d.ts.map +1 -0
  96. package/dist/transport/local/index.js +442 -0
  97. package/dist/transport/local/index.js.map +1 -0
  98. package/dist/transport/negotiator/index.d.ts +79 -0
  99. package/dist/transport/negotiator/index.d.ts.map +1 -0
  100. package/dist/transport/negotiator/index.js +289 -0
  101. package/dist/transport/negotiator/index.js.map +1 -0
  102. package/dist/transport/node/index.d.ts +56 -0
  103. package/dist/transport/node/index.d.ts.map +1 -0
  104. package/dist/transport/node/index.js +209 -0
  105. package/dist/transport/node/index.js.map +1 -0
  106. package/dist/transport/noop/index.d.ts +11 -0
  107. package/dist/transport/noop/index.d.ts.map +1 -0
  108. package/dist/transport/noop/index.js +20 -0
  109. package/dist/transport/noop/index.js.map +1 -0
  110. package/dist/transport/p2p/index.d.ts +109 -0
  111. package/dist/transport/p2p/index.d.ts.map +1 -0
  112. package/dist/transport/p2p/index.js +237 -0
  113. package/dist/transport/p2p/index.js.map +1 -0
  114. package/dist/transport/websocket/index.d.ts +89 -0
  115. package/dist/transport/websocket/index.d.ts.map +1 -0
  116. package/dist/transport/websocket/index.js +498 -0
  117. package/dist/transport/websocket/index.js.map +1 -0
  118. package/dist/transport/websocket/serialize.d.ts +5 -0
  119. package/dist/transport/websocket/serialize.d.ts.map +1 -0
  120. package/dist/transport/websocket/serialize.js +55 -0
  121. package/dist/transport/websocket/serialize.js.map +1 -0
  122. package/dist/types.d.ts +215 -0
  123. package/dist/types.d.ts.map +1 -0
  124. package/dist/types.js +15 -0
  125. package/dist/types.js.map +1 -0
  126. package/dist/x3dh/index.d.ts +120 -0
  127. package/dist/x3dh/index.d.ts.map +1 -0
  128. package/dist/x3dh/index.js +290 -0
  129. package/dist/x3dh/index.js.map +1 -0
  130. package/package.json +59 -0
  131. package/src/browser/index.ts +19 -0
  132. package/src/chaff/index.ts +340 -0
  133. package/src/cluster/index.ts +482 -0
  134. package/src/compliance/index.ts +407 -0
  135. package/src/crypto/index.ts +193 -0
  136. package/src/group/index.ts +719 -0
  137. package/src/index.ts +87 -0
  138. package/src/lz4js.d.ts +58 -0
  139. package/src/namespace/index.ts +336 -0
  140. package/src/node/index.ts +19 -0
  141. package/src/packet/index.ts +326 -0
  142. package/src/permissions/index.ts +405 -0
  143. package/src/persistence/idb-storage.ts +83 -0
  144. package/src/persistence/index.ts +3 -0
  145. package/src/persistence/node-storage.ts +96 -0
  146. package/src/persistence/serialization.ts +75 -0
  147. package/src/persistence/types.ts +33 -0
  148. package/src/ratchet/index.ts +363 -0
  149. package/src/reciprocity/index.ts +371 -0
  150. package/src/relay/index.ts +382 -0
  151. package/src/routing/index.ts +577 -0
  152. package/src/sdk/index.ts +1994 -0
  153. package/src/sybil/index.ts +661 -0
  154. package/src/transport/browser/index.ts +201 -0
  155. package/src/transport/local/index.ts +540 -0
  156. package/src/transport/negotiator/index.ts +397 -0
  157. package/src/transport/node/index.ts +234 -0
  158. package/src/transport/noop/index.ts +22 -0
  159. package/src/transport/p2p/index.ts +345 -0
  160. package/src/transport/websocket/index.ts +660 -0
  161. package/src/transport/websocket/serialize.ts +68 -0
  162. package/src/types.ts +275 -0
  163. package/src/x3dh/index.ts +388 -0
@@ -0,0 +1,1530 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Public API Surface
3
+ // The main entry point developers interact with. Wires together
4
+ // all 17 internal modules into a cohesive, ergonomic interface.
5
+ // ============================================================
6
+ import { PacketFlags } from '../types.js';
7
+ import { serializeRatchetState, deserializeRatchetState, } from '../persistence/serialization.js';
8
+ // --- Internal module imports ---
9
+ import { encrypt, decrypt, randomBytes, deriveDestHash, getCurrentEpochHour, concat, } from '../crypto/index.js';
10
+ import { generatePreKeyBundle, initiateKeyExchange, completeKeyExchange, serializePreKeyBundle, deserializePreKeyBundle, } from '../x3dh/index.js';
11
+ import { initSender, initReceiver, ratchetEncrypt, ratchetDecrypt, } from '../ratchet/index.js';
12
+ import { createDataPacket, createHandshakePacket, compressPayload, decompressPayload, PROTOCOL_VERSION, } from '../packet/index.js';
13
+ import { PlatformP2PTransport, registerPlatformBridge, } from '../transport/p2p/index.js';
14
+ import { BearerNegotiator } from '../transport/negotiator/index.js';
15
+ import { SocialGraphRouter, PeerProximityTable, } from '../routing/index.js';
16
+ import { RelayStore, StoreAndForwardManager } from '../relay/index.js';
17
+ import { RelayLedger } from '../reciprocity/index.js';
18
+ import { NamespaceManager, LocalIdentity, PeerIdentityCache, } from '../namespace/index.js';
19
+ import { PermissionManager } from '../permissions/index.js';
20
+ import { DeviceCluster } from '../cluster/index.js';
21
+ import { GroupManager } from '../group/index.js';
22
+ import { ChaffGenerator } from '../chaff/index.js';
23
+ import { EntropyChallenger, ZKRelayReputation } from '../sybil/index.js';
24
+ // ============================================================
25
+ // GroupHandle — returned by createGroup / getGroup
26
+ // ============================================================
27
+ /**
28
+ * A handle to a group that provides a `send()` method, mirroring
29
+ * the PRD's `group.send(payload)` API.
30
+ */
31
+ export class GroupHandle {
32
+ /** The underlying group metadata. */
33
+ group;
34
+ sdk;
35
+ /** @internal */
36
+ constructor(group, sdk) {
37
+ this.group = group;
38
+ this.sdk = sdk;
39
+ }
40
+ /** The group's unique ID. */
41
+ get id() {
42
+ return this.group.id;
43
+ }
44
+ /** The group's display name. */
45
+ get name() {
46
+ return this.group.name;
47
+ }
48
+ /** List of member IDs. */
49
+ get members() {
50
+ return Array.from(this.group.members.keys());
51
+ }
52
+ /**
53
+ * Send a message to all group members.
54
+ * The payload is encrypted with the local peer's sender key and
55
+ * relayed through the group's dynamic relay tree.
56
+ */
57
+ async send(payload) {
58
+ await this.sdk.sendToGroup(this.group.id, payload);
59
+ }
60
+ /** Add a member to the group. */
61
+ addMember(peerId) {
62
+ this.sdk['groupManager'].addMember(this.group.id, peerId);
63
+ }
64
+ /** Remove a member from the group. */
65
+ removeMember(peerId) {
66
+ this.sdk['groupManager'].removeMember(this.group.id, peerId);
67
+ }
68
+ }
69
+ // ============================================================
70
+ // MeshWhisper — Main SDK Class
71
+ // ============================================================
72
+ /**
73
+ * MeshWhisper is the primary API surface for the serverless P2P E2EE
74
+ * messaging SDK. Instantiate via `MeshWhisper.init(config)`, then use
75
+ * the returned instance (also accessible via `MeshWhisper.instance`)
76
+ * for all messaging operations.
77
+ *
78
+ * Static convenience methods delegate to the singleton instance.
79
+ */
80
+ export class MeshWhisper {
81
+ // --- Singleton ---
82
+ static _instance = null;
83
+ // --- Configuration ---
84
+ config;
85
+ // --- Subsystem instances ---
86
+ identity;
87
+ namespaceManager;
88
+ peerCache;
89
+ permissionManager;
90
+ negotiator;
91
+ router;
92
+ relayStore;
93
+ relayManager;
94
+ reciprocityLedger;
95
+ groupManager;
96
+ chaffGenerator;
97
+ entropyChallenger;
98
+ zkReputation;
99
+ cluster = null;
100
+ // --- Transports ---
101
+ wsTransport;
102
+ localTransport;
103
+ p2pTransport;
104
+ // --- Node/relay transport ---
105
+ nodeTransport = null;
106
+ // --- Pre-key pair storage (required for X3DH responder side) ---
107
+ signedPreKeyPair = null;
108
+ // --- Persistence ---
109
+ storage;
110
+ // --- Session state ---
111
+ sessions = new Map();
112
+ peerPreKeyBundles = new Map();
113
+ pendingHandshakes = new Map();
114
+ // --- Deduplication ---
115
+ /** Rolling set of seen message IDs to prevent duplicates. */
116
+ seenMessageIds = new Map(); // id → timestamp
117
+ // --- Presence ---
118
+ presenceRecords = new Map();
119
+ // --- Event handlers ---
120
+ onMessageHandler = null;
121
+ onPresenceHandler = null;
122
+ transportChangedHandlers = new Set();
123
+ // --- Lifecycle ---
124
+ running = false;
125
+ ephemeralRotationTimer = null;
126
+ // ================================================================
127
+ // Constructor (private — use MeshWhisper.init())
128
+ // ================================================================
129
+ constructor(config, identity, storage, wsTransport, localTransport, nodeTransport) {
130
+ this.config = config;
131
+ this.storage = storage;
132
+ // --- Identity ---
133
+ this.identity = identity;
134
+ const developerKeyBytes = config.developerKey
135
+ ? base64ToUint8Array(config.developerKey)
136
+ : randomBytes(32); // random key for dev/single-tenant use
137
+ const namespaceSalt = randomBytes(32);
138
+ this.namespaceManager = new NamespaceManager({
139
+ appBundleId: config.namespace,
140
+ developerPublicKey: developerKeyBytes,
141
+ salt: namespaceSalt,
142
+ });
143
+ this.peerCache = new PeerIdentityCache();
144
+ // --- Permissions ---
145
+ this.permissionManager = new PermissionManager(config.permissionModel ?? 'open');
146
+ // --- Transports ---
147
+ this.wsTransport = wsTransport;
148
+ this.localTransport = localTransport;
149
+ this.nodeTransport = nodeTransport;
150
+ this.p2pTransport = new PlatformP2PTransport(config.namespace);
151
+ this.negotiator = new BearerNegotiator([
152
+ this.p2pTransport,
153
+ this.localTransport,
154
+ this.nodeTransport,
155
+ this.wsTransport,
156
+ ]);
157
+ // --- Routing ---
158
+ const localPeerId = this.getLocalPeerId();
159
+ const proximityTable = new PeerProximityTable();
160
+ this.router = new SocialGraphRouter(localPeerId, proximityTable);
161
+ // --- Relay (store-and-forward) ---
162
+ const storeTTL = config.config?.storeTTL ?? 72;
163
+ this.relayStore = new RelayStore({ defaultTTLHours: storeTTL });
164
+ this.relayManager = new StoreAndForwardManager(this.relayStore);
165
+ // --- Reciprocity ---
166
+ this.reciprocityLedger = new RelayLedger();
167
+ this.reciprocityLedger.registerDevice(localPeerId);
168
+ // --- Groups ---
169
+ this.groupManager = new GroupManager(localPeerId);
170
+ // --- Chaff ---
171
+ const chaffRate = config.config?.chaffRate ?? 'normal';
172
+ this.chaffGenerator = new ChaffGenerator({ rate: chaffRate });
173
+ // --- Sybil resistance ---
174
+ this.entropyChallenger = new EntropyChallenger();
175
+ this.zkReputation = new ZKRelayReputation(localPeerId);
176
+ // --- Cluster (optional) ---
177
+ if (config.config?.clusterEnabled !== false) {
178
+ this.cluster = new DeviceCluster(this.identity.getPublicKey(), localPeerId);
179
+ }
180
+ // --- Event handlers from config ---
181
+ this.onMessageHandler = config.onMessage ?? null;
182
+ this.onPresenceHandler = config.onPresence ?? null;
183
+ }
184
+ // ================================================================
185
+ // Initialization — static entry point
186
+ // ================================================================
187
+ /**
188
+ * Initialize the MeshWhisper SDK with the given configuration.
189
+ *
190
+ * ```ts
191
+ * const mw = await MeshWhisper.init({
192
+ * namespace: "com.example.fitnessapp",
193
+ * developerKey: "base64-encoded-public-key",
194
+ * permissionModel: "mutual",
195
+ * onMessage: (message) => { ... },
196
+ * onPresence: (peer, status) => { ... },
197
+ * config: {
198
+ * relayWillingness: "auto",
199
+ * chaffRate: "normal",
200
+ * storeTTL: 72,
201
+ * clusterEnabled: true,
202
+ * },
203
+ * });
204
+ * ```
205
+ */
206
+ static async init(config) {
207
+ if (MeshWhisper._instance) {
208
+ await MeshWhisper._instance.shutdown();
209
+ }
210
+ // ---- Environment detection ----
211
+ const isBrowser = typeof window !== 'undefined' && typeof indexedDB !== 'undefined';
212
+ // ---- Storage ----
213
+ let storage = config.storage ?? null;
214
+ if (!storage && isBrowser) {
215
+ const { IDBStorage } = await import('../persistence/idb-storage.js');
216
+ storage = new IDBStorage(config.namespace);
217
+ }
218
+ // ---- Identity ----
219
+ // Loaded from storage so the peer ID stays stable across page reloads /
220
+ // process restarts. Falls back to a fresh ephemeral identity when storage
221
+ // is unavailable (development / anonymous use).
222
+ let identity;
223
+ if (storage) {
224
+ const savedKey = await storage.get('identity');
225
+ if (savedKey) {
226
+ identity = LocalIdentity.fromPrivateKey(hexToUint8Array(savedKey));
227
+ }
228
+ else {
229
+ identity = LocalIdentity.create();
230
+ await storage.set('identity', uint8ArrayToHex(identity.getEdPrivateKey()));
231
+ }
232
+ }
233
+ else {
234
+ identity = LocalIdentity.create();
235
+ }
236
+ // ---- Relay URL ----
237
+ const nodeConfig = config.node ?? 'mesh';
238
+ const nodeUrls = Array.isArray(nodeConfig) ? nodeConfig : [nodeConfig];
239
+ const primaryNodeUrl = nodeUrls[0];
240
+ // ---- Transports (platform-appropriate) ----
241
+ let wsTransport;
242
+ let localTransport;
243
+ let nodeTransport;
244
+ // Declare instance early so the transport closures can capture it by
245
+ // reference. The getDestHashes callback is only ever invoked after
246
+ // instance.start() is awaited below, so the assignment is safe.
247
+ let instance;
248
+ const getDestHashes = () => instance.getCurrentDestHashes();
249
+ if (isBrowser) {
250
+ // In a browser/PWA: relay via BrowserTransport, stub out Node.js-only
251
+ // transports (WebSocketTransport for P2P, LocalTransport for LAN).
252
+ const [{ NoOpTransport }, { BrowserTransport }] = await Promise.all([
253
+ import('../transport/noop/index.js'),
254
+ import('../transport/browser/index.js'),
255
+ ]);
256
+ wsTransport = new NoOpTransport('internet');
257
+ localTransport = new NoOpTransport('local_net');
258
+ nodeTransport = new BrowserTransport(primaryNodeUrl, getDestHashes, config.push);
259
+ }
260
+ else {
261
+ // In Node.js: full transport stack.
262
+ const [{ WebSocketTransport }, { LocalTransport }, { NodeTransport }] = await Promise.all([
263
+ import('../transport/websocket/index.js'),
264
+ import('../transport/local/index.js'),
265
+ import('../transport/node/index.js'),
266
+ ]);
267
+ const deviceId = randomBytes(16);
268
+ wsTransport = new WebSocketTransport();
269
+ localTransport = new LocalTransport(deviceId);
270
+ nodeTransport = new NodeTransport(primaryNodeUrl, getDestHashes, config.push);
271
+ }
272
+ instance = new MeshWhisper(config, identity, storage, wsTransport, localTransport, nodeTransport);
273
+ MeshWhisper._instance = instance;
274
+ await instance.start();
275
+ return instance;
276
+ }
277
+ /**
278
+ * Returns the active MeshWhisper instance, or throws if not initialized.
279
+ */
280
+ static get instance() {
281
+ if (!MeshWhisper._instance) {
282
+ throw new Error('MeshWhisper has not been initialized. Call MeshWhisper.init() first.');
283
+ }
284
+ return MeshWhisper._instance;
285
+ }
286
+ // ================================================================
287
+ // Lifecycle
288
+ // ================================================================
289
+ async start() {
290
+ if (this.running)
291
+ return;
292
+ this.running = true;
293
+ // --- Restore persisted state ---
294
+ if (this.storage) {
295
+ await this.loadPersistedState();
296
+ // If we have contacts but no sessions, the session state was lost
297
+ // (storage wipe, new device with same identity key). Re-initiate X3DH
298
+ // with every contact we have a saved prekey bundle for.
299
+ if (this.sessions.size === 0 && this.permissionManager.getContacts().length > 0) {
300
+ this.reinitiateSessionsOnStartup().catch(() => { });
301
+ }
302
+ }
303
+ // Wire up the unified receive handler across all transports
304
+ this.negotiator.onReceive((packet, source, bearer) => {
305
+ this.handleIncomingPacket(packet, source, bearer);
306
+ });
307
+ // Generate pre-key bundle on startup so the private keys are available
308
+ // when an X3DH handshake arrives. Store the signed pre-key pair here.
309
+ const edKeyPair = {
310
+ publicKey: this.identity.getEdPublicKey(),
311
+ privateKey: this.identity['edPrivateKey'],
312
+ };
313
+ const { signedPreKeyPair } = generatePreKeyBundle(edKeyPair);
314
+ this.signedPreKeyPair = signedPreKeyPair;
315
+ // Start transports (best-effort; some may not be available)
316
+ const startResults = await Promise.allSettled([
317
+ this.wsTransport.start(),
318
+ this.localTransport.start(),
319
+ this.p2pTransport.start(),
320
+ this.nodeTransport?.start() ?? Promise.resolve(),
321
+ ]);
322
+ // Emit transport availability events (node transport doesn't map to a distinct bearer type)
323
+ const transportTypes = ['internet', 'local_net', 'platform_p2p', 'internet'];
324
+ for (let i = 0; i < startResults.length; i++) {
325
+ const available = startResults[i].status === 'fulfilled';
326
+ for (const handler of this.transportChangedHandlers) {
327
+ try {
328
+ handler({ type: transportTypes[i], available });
329
+ }
330
+ catch {
331
+ // Swallow handler errors
332
+ }
333
+ }
334
+ }
335
+ // Start chaff generator — wire output into the negotiator
336
+ this.chaffGenerator.onChaffGenerated((packet) => {
337
+ this.negotiator.broadcast(packet).catch(() => {
338
+ // Best effort for chaff
339
+ });
340
+ });
341
+ this.chaffGenerator.start();
342
+ // Start relay store pruning
343
+ this.relayStore.startPruneInterval();
344
+ // Start device cluster
345
+ if (this.cluster) {
346
+ const localDevice = {
347
+ deviceId: this.getLocalPeerId(),
348
+ clusterKey: this.cluster.getClusterKey(),
349
+ capabilities: await this.negotiator.probeAvailability(),
350
+ isPrimary: false,
351
+ lastSync: Date.now(),
352
+ };
353
+ this.cluster.addDevice(localDevice);
354
+ this.cluster.start();
355
+ }
356
+ // Rotate ephemeral sender ID every 10 minutes
357
+ this.ephemeralRotationTimer = setInterval(() => {
358
+ this.identity.rotateEphemeralId();
359
+ }, 10 * 60 * 1000);
360
+ if (typeof this.ephemeralRotationTimer === 'object' && 'unref' in this.ephemeralRotationTimer) {
361
+ this.ephemeralRotationTimer.unref();
362
+ }
363
+ }
364
+ /**
365
+ * Shut down the SDK, stopping all transports, timers, and subsystems.
366
+ * After calling `shutdown()`, you must call `MeshWhisper.init()` again
367
+ * to resume operation.
368
+ */
369
+ async shutdown() {
370
+ if (!this.running)
371
+ return;
372
+ this.running = false;
373
+ // Persist state before stopping (sessions are also saved incrementally,
374
+ // but contacts and peers are only saved here and on mutations)
375
+ if (this.storage) {
376
+ await this.persistContacts();
377
+ await this.persistPeers();
378
+ await this.persistSeenIds();
379
+ }
380
+ // Stop chaff
381
+ this.chaffGenerator.stop();
382
+ // Stop relay pruning
383
+ this.relayStore.stopPruneInterval();
384
+ // Stop cluster
385
+ if (this.cluster) {
386
+ this.cluster.stop();
387
+ }
388
+ // Stop ephemeral rotation
389
+ if (this.ephemeralRotationTimer) {
390
+ clearInterval(this.ephemeralRotationTimer);
391
+ this.ephemeralRotationTimer = null;
392
+ }
393
+ // Stop transports
394
+ await Promise.allSettled([
395
+ this.wsTransport.stop(),
396
+ this.localTransport.stop(),
397
+ this.p2pTransport.stop(),
398
+ ]);
399
+ MeshWhisper._instance = null;
400
+ }
401
+ // ================================================================
402
+ // Public API — Messaging
403
+ // ================================================================
404
+ /**
405
+ * Send an encrypted message to a recipient.
406
+ *
407
+ * If no session exists with the recipient, an X3DH handshake is
408
+ * automatically initiated. Messages are compressed, encrypted via
409
+ * the Double Ratchet, assembled into a packet, and routed through
410
+ * the best available transport.
411
+ *
412
+ * ```ts
413
+ * await MeshWhisper.send(recipientId, payload, {
414
+ * urgency: "normal",
415
+ * expiry: 3600,
416
+ * });
417
+ * ```
418
+ */
419
+ static async send(recipientId, payload, options) {
420
+ return MeshWhisper.instance.sendMessage(recipientId, payload, options);
421
+ }
422
+ async sendMessage(recipientId, payload, options) {
423
+ this.assertRunning();
424
+ // Check permissions
425
+ const canSend = await this.permissionManager.canSendTo(recipientId);
426
+ if (!canSend) {
427
+ throw new Error(`Permission denied: cannot send to ${recipientId}`);
428
+ }
429
+ // Ensure we have a ratchet session with this peer
430
+ await this.ensureSession(recipientId);
431
+ const session = this.sessions.get(recipientId);
432
+ if (!session) {
433
+ throw new Error(`Failed to establish session with ${recipientId}`);
434
+ }
435
+ // Build message envelope
436
+ const messageId = generateMessageId();
437
+ const envelope = {
438
+ id: messageId,
439
+ senderId: this.getLocalPeerId(),
440
+ recipientId,
441
+ payload: Array.from(payload),
442
+ timestamp: Date.now(),
443
+ urgency: options?.urgency ?? 'normal',
444
+ expiry: options?.expiry,
445
+ };
446
+ // Serialize, compress, encrypt
447
+ const envelopeBytes = new TextEncoder().encode(JSON.stringify(envelope));
448
+ const compressed = compressPayload(envelopeBytes);
449
+ const { state: newState, header, ciphertext } = ratchetEncrypt(session, compressed);
450
+ this.sessions.set(recipientId, newState);
451
+ // Embed ratchet header into the ciphertext for the receiver
452
+ const headerBytes = serializeRatchetHeader(header);
453
+ const fullPayload = concat(headerBytes, ciphertext);
454
+ // Build packet
455
+ const recipientPublicKey = this.peerCache.getPeerPublicKey(recipientId);
456
+ if (!recipientPublicKey) {
457
+ throw new Error(`No public key for recipient ${recipientId}`);
458
+ }
459
+ const destHash = deriveDestHash(recipientPublicKey, getCurrentEpochHour());
460
+ const senderEphId = this.identity.generateEphemeralId();
461
+ const packet = createDataPacket(destHash, senderEphId, fullPayload);
462
+ // Camouflage with chaff
463
+ const burst = this.chaffGenerator.camouflageRealMessage(packet);
464
+ // Route and send each packet in the burst
465
+ for (const p of burst) {
466
+ await this.routeAndSend(p, recipientId);
467
+ }
468
+ // Persist the outbound message (skip internal control messages)
469
+ const isControl = isControlPayload(payload);
470
+ if (!isControl) {
471
+ await this.saveMessage({
472
+ id: messageId,
473
+ conversationId: recipientId,
474
+ senderId: this.getLocalPeerId(),
475
+ recipientId,
476
+ payload: Array.from(payload),
477
+ timestamp: envelope.timestamp,
478
+ direction: 'outbound',
479
+ status: 'sent',
480
+ });
481
+ }
482
+ }
483
+ // ================================================================
484
+ // Public API — Media
485
+ // ================================================================
486
+ /**
487
+ * Send media to a recipient using the two-part flow:
488
+ * 1. Encrypt locally with a random AES-256-GCM key.
489
+ * 2. Upload the ciphertext to the Node (or a custom handler).
490
+ * 3. Send the URL + key through the normal encrypted message channel.
491
+ *
492
+ * The Node never receives the decryption key.
493
+ *
494
+ * ```ts
495
+ * await MeshWhisper.sendMedia(recipientId, imageBytes, { mimeType: 'image/jpeg' });
496
+ * ```
497
+ */
498
+ static async sendMedia(recipientId, data, options) {
499
+ return MeshWhisper.instance.sendMediaMessage(recipientId, data, options);
500
+ }
501
+ async sendMediaMessage(recipientId, data, options) {
502
+ this.assertRunning();
503
+ // 1. Encrypt the media locally
504
+ const mediaKey = randomBytes(32);
505
+ const { ciphertext, nonce, tag } = encrypt(data, mediaKey);
506
+ const encryptedBlob = concat(nonce, tag, ciphertext);
507
+ // 2. Upload — use custom handler if provided, else POST to Node
508
+ let url;
509
+ if (options?.upload) {
510
+ url = await options.upload(encryptedBlob);
511
+ }
512
+ else {
513
+ url = await this.uploadMediaToNode(encryptedBlob);
514
+ }
515
+ // 3. Send pointer message through normal encrypted channel
516
+ const mediaMsg = {
517
+ url,
518
+ key: uint8ArrayToBase64(mediaKey),
519
+ ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
520
+ };
521
+ const pointer = new TextEncoder().encode(JSON.stringify({ __mw_media: true, ...mediaMsg }));
522
+ await this.sendMessage(recipientId, pointer, options);
523
+ }
524
+ /**
525
+ * Download and decrypt a media message received via `onMessage`.
526
+ * Detects messages produced by `sendMedia` automatically.
527
+ *
528
+ * ```ts
529
+ * const bytes = await MeshWhisper.downloadMedia(message);
530
+ * ```
531
+ */
532
+ static async downloadMedia(message) {
533
+ return MeshWhisper.instance.downloadMediaMessage(message);
534
+ }
535
+ async downloadMediaMessage(message) {
536
+ let parsed;
537
+ try {
538
+ const text = new TextDecoder().decode(new Uint8Array(message.payload));
539
+ parsed = JSON.parse(text);
540
+ }
541
+ catch {
542
+ return null;
543
+ }
544
+ if (!parsed.__mw_media || !parsed.url || !parsed.key)
545
+ return null;
546
+ const mediaKey = base64ToUint8Array(parsed.key);
547
+ // Fetch encrypted blob
548
+ const response = await fetch(parsed.url);
549
+ if (!response.ok)
550
+ throw new Error(`Media fetch failed: ${response.status}`);
551
+ const blob = new Uint8Array(await response.arrayBuffer());
552
+ // Unpack nonce (12) + tag (16) + ciphertext
553
+ const nonce = blob.slice(0, 12);
554
+ const tag = blob.slice(12, 28);
555
+ const ciphertext = blob.slice(28);
556
+ return decrypt({ nonce, tag, ciphertext }, mediaKey);
557
+ }
558
+ async uploadMediaToNode(encryptedBlob) {
559
+ const nodeConfig = this.config.node ?? 'mesh';
560
+ const nodeUrl = Array.isArray(nodeConfig) ? nodeConfig[0] : nodeConfig;
561
+ // Convert WebSocket URL to HTTP URL
562
+ const httpUrl = nodeUrl === 'mesh'
563
+ ? 'https://relay.meshwhisper.io/media'
564
+ : nodeUrl.replace(/^wss?:\/\//, (m) => m === 'wss://' ? 'https://' : 'http://') + '/media';
565
+ const response = await fetch(httpUrl, {
566
+ method: 'POST',
567
+ headers: { 'Content-Type': 'application/octet-stream' },
568
+ body: encryptedBlob.buffer,
569
+ });
570
+ if (!response.ok) {
571
+ throw new Error(`Media upload failed: ${response.status}`);
572
+ }
573
+ const json = await response.json();
574
+ if (!json.url)
575
+ throw new Error('Media upload: Node returned no URL');
576
+ return json.url;
577
+ }
578
+ // ================================================================
579
+ // Public API — Groups
580
+ // ================================================================
581
+ /**
582
+ * Create a new group.
583
+ *
584
+ * ```ts
585
+ * const group = MeshWhisper.createGroup({
586
+ * name: "Team Chat",
587
+ * members: [id1, id2, id3],
588
+ * permissionModel: "open",
589
+ * });
590
+ * group.send(payload);
591
+ * ```
592
+ */
593
+ static createGroup(options) {
594
+ return MeshWhisper.instance.createGroupInstance(options);
595
+ }
596
+ createGroupInstance(options) {
597
+ this.assertRunning();
598
+ const group = this.groupManager.createGroup(options.name, options.members, options.permissionModel ?? 'open');
599
+ return new GroupHandle(group, this);
600
+ }
601
+ /**
602
+ * Retrieve a group handle by ID.
603
+ * Returns null if the group is not found.
604
+ */
605
+ getGroup(groupId) {
606
+ const group = this.groupManager.getGroup(groupId);
607
+ if (!group)
608
+ return null;
609
+ return new GroupHandle(group, this);
610
+ }
611
+ /**
612
+ * List all groups the local peer is participating in.
613
+ */
614
+ getGroups() {
615
+ return this.groupManager.getGroups().map(g => new GroupHandle(g, this));
616
+ }
617
+ /**
618
+ * Send a message to all members of a group.
619
+ * @internal — use GroupHandle.send() instead.
620
+ */
621
+ async sendToGroup(groupId, payload) {
622
+ this.assertRunning();
623
+ const { ciphertext, senderId } = this.groupManager.encryptForGroup(groupId, payload);
624
+ const targets = this.groupManager.routeGroupMessage(groupId, ciphertext, senderId);
625
+ const sendPromises = targets.map(async (target) => {
626
+ try {
627
+ const recipientPublicKey = this.peerCache.getPeerPublicKey(target.peerId);
628
+ if (!recipientPublicKey)
629
+ return;
630
+ const destHash = deriveDestHash(recipientPublicKey, getCurrentEpochHour());
631
+ const senderEphId = this.identity.generateEphemeralId();
632
+ const packet = createDataPacket(destHash, senderEphId, target.data);
633
+ await this.routeAndSend(packet, target.peerId);
634
+ }
635
+ catch {
636
+ // Best effort per member
637
+ }
638
+ });
639
+ await Promise.allSettled(sendPromises);
640
+ }
641
+ // ================================================================
642
+ // Public API — Contacts & Identity
643
+ // ================================================================
644
+ /**
645
+ * Generate a QR code payload for first contact. Contains the
646
+ * local peer's identity public key and pre-key bundle so a
647
+ * scanner can initiate an X3DH handshake.
648
+ *
649
+ * Returns a base64-encoded string suitable for embedding in a QR code.
650
+ */
651
+ static generateContactQR() {
652
+ return MeshWhisper.instance.generateContactQRInstance();
653
+ }
654
+ generateContactQRInstance() {
655
+ this.assertRunning();
656
+ const edKeyPair = {
657
+ publicKey: this.identity.getEdPublicKey(),
658
+ privateKey: this.identity['edPrivateKey'],
659
+ };
660
+ const { bundle, signedPreKeyPair } = generatePreKeyBundle(edKeyPair);
661
+ this.signedPreKeyPair = signedPreKeyPair;
662
+ const serialized = serializePreKeyBundle(bundle);
663
+ // Encode as: peerId-length(2) + peerId-bytes + bundle-bytes
664
+ const peerIdBytes = new TextEncoder().encode(this.getLocalPeerId());
665
+ const lenBuf = new Uint8Array(2);
666
+ new DataView(lenBuf.buffer).setUint16(0, peerIdBytes.length, false);
667
+ const qrPayload = concat(lenBuf, peerIdBytes, serialized);
668
+ return uint8ArrayToBase64(qrPayload);
669
+ }
670
+ /**
671
+ * Accept a contact from scanned QR data. Parses the peer's
672
+ * pre-key bundle and initiates an X3DH handshake.
673
+ */
674
+ static async acceptContact(scannedQRData) {
675
+ return MeshWhisper.instance.acceptContactInstance(scannedQRData);
676
+ }
677
+ async acceptContactInstance(scannedQRData) {
678
+ this.assertRunning();
679
+ const raw = base64ToUint8Array(scannedQRData);
680
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
681
+ const peerIdLen = view.getUint16(0, false);
682
+ const peerIdBytes = raw.slice(2, 2 + peerIdLen);
683
+ const peerId = new TextDecoder().decode(peerIdBytes);
684
+ const bundleBytes = raw.slice(2 + peerIdLen);
685
+ const bundle = deserializePreKeyBundle(bundleBytes);
686
+ // Store and persist the peer's prekey bundle
687
+ this.peerPreKeyBundles.set(peerId, bundle);
688
+ this.persistPreKeyBundle(peerId, bundle).catch(() => { });
689
+ // For mutual model, register the contact request
690
+ if (this.config.permissionModel === 'mutual') {
691
+ this.permissionManager.confirmMutualContact(peerId);
692
+ }
693
+ else {
694
+ this.permissionManager.addContact(peerId);
695
+ }
696
+ // Cache the peer's public key (convert Ed25519 identity key to X25519)
697
+ // The bundle identity key is Ed25519; the signed pre-key is X25519.
698
+ // For dest_hash we need the X25519 key derived from the identity key.
699
+ // Store the signed pre-key as the peer's X25519 public key for routing.
700
+ this.peerCache.addPeer(peerId, bundle.signedPreKey);
701
+ // Initiate X3DH session
702
+ await this.initiateHandshake(peerId, bundle);
703
+ }
704
+ /**
705
+ * Introduce two contacts to each other.
706
+ * Both peers must already be contacts of the local peer.
707
+ */
708
+ static async introduceContacts(peerA, peerB) {
709
+ return MeshWhisper.instance.introduceContactsInstance(peerA, peerB);
710
+ }
711
+ async introduceContactsInstance(peerA, peerB) {
712
+ this.assertRunning();
713
+ if (!this.permissionManager.isContact(peerA)) {
714
+ throw new Error(`${peerA} is not a contact`);
715
+ }
716
+ if (!this.permissionManager.isContact(peerB)) {
717
+ throw new Error(`${peerB} is not a contact`);
718
+ }
719
+ // Send each peer the other's pre-key bundle so they can establish
720
+ // a session directly.
721
+ const pubKeyA = this.peerCache.getPeerPublicKey(peerA);
722
+ const pubKeyB = this.peerCache.getPeerPublicKey(peerB);
723
+ if (pubKeyA && pubKeyB) {
724
+ // Craft introduction messages containing the peer's public key
725
+ const introForA = new TextEncoder().encode(JSON.stringify({
726
+ type: 'introduction',
727
+ introducedPeerId: peerB,
728
+ introducedPublicKey: Array.from(pubKeyB),
729
+ introducedBy: this.getLocalPeerId(),
730
+ }));
731
+ const introForB = new TextEncoder().encode(JSON.stringify({
732
+ type: 'introduction',
733
+ introducedPeerId: peerA,
734
+ introducedPublicKey: Array.from(pubKeyA),
735
+ introducedBy: this.getLocalPeerId(),
736
+ }));
737
+ await Promise.allSettled([
738
+ this.sendMessage(peerA, introForA),
739
+ this.sendMessage(peerB, introForB),
740
+ ]);
741
+ }
742
+ }
743
+ // ================================================================
744
+ // Public API — Presence
745
+ // ================================================================
746
+ /**
747
+ * Get the current presence status of a peer.
748
+ */
749
+ static getPresence(peerId) {
750
+ return MeshWhisper.instance.getPresenceInstance(peerId);
751
+ }
752
+ getPresenceInstance(peerId) {
753
+ const record = this.presenceRecords.get(peerId);
754
+ if (!record)
755
+ return 'unknown';
756
+ const elapsed = Date.now() - record.lastSeen;
757
+ if (elapsed < 5 * 60 * 1000)
758
+ return 'online';
759
+ if (elapsed < 60 * 60 * 1000)
760
+ return 'recently_seen';
761
+ return 'offline';
762
+ }
763
+ // ================================================================
764
+ // Public API — Transport Events
765
+ // ================================================================
766
+ /**
767
+ * Register a callback that fires when transport availability changes.
768
+ */
769
+ static onTransportChanged(handler) {
770
+ MeshWhisper.instance.onTransportChangedInstance(handler);
771
+ }
772
+ onTransportChangedInstance(handler) {
773
+ this.transportChangedHandlers.add(handler);
774
+ }
775
+ /**
776
+ * Unregister a transport-changed handler.
777
+ */
778
+ offTransportChanged(handler) {
779
+ this.transportChangedHandlers.delete(handler);
780
+ }
781
+ // ================================================================
782
+ // Public API — Accessors
783
+ // ================================================================
784
+ /**
785
+ * Returns the local peer's public identity string (hex-encoded X25519 public key).
786
+ */
787
+ getLocalPeerId() {
788
+ return uint8ArrayToHex(this.identity.getPublicKey());
789
+ }
790
+ /**
791
+ * Returns the device's current and previous epoch-hour destination hashes
792
+ * as hex strings. Used by NodeTransport to register with the Node.
793
+ */
794
+ getCurrentDestHashes() {
795
+ const xPub = this.identity.getPublicKey();
796
+ const hour = getCurrentEpochHour();
797
+ return [
798
+ uint8ArrayToHex(deriveDestHash(xPub, hour)),
799
+ uint8ArrayToHex(deriveDestHash(xPub, hour - 1)),
800
+ ];
801
+ }
802
+ /**
803
+ * Returns the local peer's X25519 public key bytes.
804
+ */
805
+ getPublicKey() {
806
+ return this.identity.getPublicKey();
807
+ }
808
+ /**
809
+ * Returns the namespace ID for this SDK instance.
810
+ */
811
+ getNamespaceId() {
812
+ return this.namespaceManager.getNamespaceId();
813
+ }
814
+ /**
815
+ * Whether the SDK is currently running.
816
+ */
817
+ isRunning() {
818
+ return this.running;
819
+ }
820
+ /**
821
+ * Register a native P2P bridge (e.g., Apple Multipeer Connectivity
822
+ * or Google Nearby Connections). Call before `init()` for the bridge
823
+ * to be available at startup.
824
+ */
825
+ static registerPlatformBridge(bridge) {
826
+ registerPlatformBridge(bridge);
827
+ }
828
+ // ================================================================
829
+ // Internal — Incoming Packet Handling
830
+ // ================================================================
831
+ handleIncomingPacket(packet, source, bearer) {
832
+ // Update presence for the source
833
+ this.updatePresence(source, 'online');
834
+ // Track reciprocity for relayed packets
835
+ this.reciprocityLedger.recordPeerRelayedForUs(source, packet.encryptedPayload.length);
836
+ // Drop chaff packets silently
837
+ if (packet.flags === PacketFlags.CHAFF) {
838
+ return;
839
+ }
840
+ // Check if the packet is destined for us
841
+ const isForUs = this.identity.matchesDestHash(packet.destHash);
842
+ if (isForUs) {
843
+ this.processLocalPacket(packet, source);
844
+ }
845
+ else {
846
+ // Not for us — consider relaying
847
+ this.maybeRelay(packet, source);
848
+ }
849
+ }
850
+ processLocalPacket(packet, source) {
851
+ switch (packet.flags) {
852
+ case PacketFlags.HANDSHAKE:
853
+ this.handleHandshakePacket(packet, source);
854
+ break;
855
+ case PacketFlags.DATA:
856
+ this.handleDataPacket(packet, source);
857
+ break;
858
+ case PacketFlags.ACK:
859
+ // ACKs are currently handled implicitly by the ratchet
860
+ break;
861
+ case PacketFlags.ROUTE_REQUEST:
862
+ this.handleRouteRequestPacket(packet, source);
863
+ break;
864
+ case PacketFlags.ROUTE_OFFER:
865
+ this.handleRouteOfferPacket(packet, source);
866
+ break;
867
+ default:
868
+ // Unknown flag — drop silently
869
+ break;
870
+ }
871
+ }
872
+ handleDataPacket(packet, source) {
873
+ try {
874
+ const { header, ciphertextBody } = deserializeRatchetHeader(packet.encryptedPayload);
875
+ let decrypted = null;
876
+ let matchedPeerId = null;
877
+ let newState = null;
878
+ for (const [peerId, session] of this.sessions) {
879
+ try {
880
+ const result = ratchetDecrypt(session, header, ciphertextBody);
881
+ newState = result.state;
882
+ decrypted = result.plaintext;
883
+ matchedPeerId = peerId;
884
+ break;
885
+ }
886
+ catch {
887
+ continue;
888
+ }
889
+ }
890
+ if (!decrypted || !matchedPeerId || !newState)
891
+ return;
892
+ // Persist the advanced ratchet state immediately
893
+ this.sessions.set(matchedPeerId, newState);
894
+ this.persistSession(matchedPeerId, newState).catch(() => { });
895
+ // Decompress and parse envelope
896
+ const decompressed = decompressPayload(decrypted);
897
+ const envelope = JSON.parse(new TextDecoder().decode(decompressed));
898
+ // --- Deduplication ---
899
+ if (this.seenMessageIds.has(envelope.id))
900
+ return;
901
+ this.seenMessageIds.set(envelope.id, envelope.timestamp);
902
+ this.pruneSeenIds();
903
+ // --- Internal control messages (delivery receipts etc.) ---
904
+ const payloadBytes = new Uint8Array(envelope.payload);
905
+ const ctrl = tryParseControl(payloadBytes);
906
+ if (ctrl) {
907
+ this.handleControlMessage(ctrl, matchedPeerId);
908
+ return;
909
+ }
910
+ // --- Expiry check ---
911
+ if (envelope.expiry) {
912
+ if (Date.now() > envelope.timestamp + envelope.expiry * 1000)
913
+ return;
914
+ }
915
+ const message = {
916
+ id: envelope.id,
917
+ senderId: envelope.senderId,
918
+ recipientId: envelope.recipientId,
919
+ payload: payloadBytes,
920
+ timestamp: envelope.timestamp,
921
+ urgency: envelope.urgency,
922
+ expiry: envelope.expiry,
923
+ };
924
+ // --- Persist inbound message ---
925
+ this.saveMessage({
926
+ id: message.id,
927
+ conversationId: matchedPeerId,
928
+ senderId: message.senderId,
929
+ recipientId: message.recipientId,
930
+ payload: Array.from(payloadBytes),
931
+ timestamp: message.timestamp,
932
+ direction: 'inbound',
933
+ status: 'delivered',
934
+ }).catch(() => { });
935
+ // --- Surface to app ---
936
+ if (this.onMessageHandler) {
937
+ this.onMessageHandler(message);
938
+ }
939
+ // --- Send DELIVERED receipt ---
940
+ this.sendControl(matchedPeerId, { __mw_ctrl: 'delivered', messageId: message.id });
941
+ // --- Cluster sync ---
942
+ if (this.cluster) {
943
+ this.cluster.syncManager.queueForSync({
944
+ messageId: message.id,
945
+ encryptedPayload: packet.encryptedPayload,
946
+ receivedAt: Date.now(),
947
+ receivedBy: this.getLocalPeerId(),
948
+ syncedTo: new Set([this.getLocalPeerId()]),
949
+ });
950
+ }
951
+ }
952
+ catch {
953
+ // Malformed packet — drop silently
954
+ }
955
+ }
956
+ handleHandshakePacket(packet, source) {
957
+ try {
958
+ const envelopeStr = new TextDecoder().decode(packet.encryptedPayload);
959
+ const envelope = JSON.parse(envelopeStr);
960
+ switch (envelope.type) {
961
+ case 'prekey_bundle': {
962
+ if (envelope.preKeyBundle) {
963
+ const bundleBytes = new Uint8Array(envelope.preKeyBundle);
964
+ const bundle = deserializePreKeyBundle(bundleBytes);
965
+ this.peerPreKeyBundles.set(envelope.senderId, bundle);
966
+ this.peerCache.addPeer(envelope.senderId, bundle.signedPreKey);
967
+ this.persistPreKeyBundle(envelope.senderId, bundle).catch(() => { });
968
+ }
969
+ break;
970
+ }
971
+ case 'x3dh_init': {
972
+ // Responder side: complete the X3DH exchange
973
+ if (envelope.ephemeralPublicKey && envelope.identityKey) {
974
+ this.completeIncomingHandshake(envelope);
975
+ }
976
+ break;
977
+ }
978
+ case 'x3dh_response': {
979
+ // Resolve any pending handshake
980
+ const pending = this.pendingHandshakes.get(envelope.senderId);
981
+ if (pending) {
982
+ pending.resolve();
983
+ this.pendingHandshakes.delete(envelope.senderId);
984
+ }
985
+ break;
986
+ }
987
+ }
988
+ }
989
+ catch {
990
+ // Malformed handshake — drop
991
+ }
992
+ }
993
+ handleRouteRequestPacket(packet, source) {
994
+ // Attempt to parse the route request from the payload
995
+ try {
996
+ const requestStr = new TextDecoder().decode(packet.encryptedPayload);
997
+ const request = JSON.parse(requestStr);
998
+ const offer = this.router.handleRouteRequest({
999
+ destHash: new Uint8Array(request.destHash),
1000
+ requestId: new Uint8Array(request.requestId),
1001
+ ttl: request.ttl,
1002
+ timestamp: request.timestamp,
1003
+ }, source);
1004
+ if (offer) {
1005
+ // Send route offer back to the requesting peer
1006
+ const offerPayload = new TextEncoder().encode(JSON.stringify(offer));
1007
+ const destHash = packet.destHash; // Reply to the requester's hash
1008
+ const senderEphId = this.identity.generateEphemeralId();
1009
+ const offerPacket = {
1010
+ version: PROTOCOL_VERSION,
1011
+ flags: PacketFlags.ROUTE_OFFER,
1012
+ destHash,
1013
+ senderEphemeralId: senderEphId,
1014
+ ttl: packet.ttl,
1015
+ payloadLength: offerPayload.length,
1016
+ encryptedPayload: offerPayload,
1017
+ };
1018
+ this.negotiator.send(offerPacket, source).catch(() => {
1019
+ // Best effort
1020
+ });
1021
+ }
1022
+ }
1023
+ catch {
1024
+ // Malformed route request — drop
1025
+ }
1026
+ }
1027
+ handleRouteOfferPacket(packet, _source) {
1028
+ try {
1029
+ const offerStr = new TextDecoder().decode(packet.encryptedPayload);
1030
+ const offer = JSON.parse(offerStr);
1031
+ this.router.handleRouteOffer({
1032
+ requestId: new Uint8Array(offer.requestId),
1033
+ hopCount: offer.hopCount,
1034
+ estimatedLatency: offer.estimatedLatency,
1035
+ offeredBy: offer.offeredBy,
1036
+ });
1037
+ }
1038
+ catch {
1039
+ // Malformed route offer — drop
1040
+ }
1041
+ }
1042
+ // ================================================================
1043
+ // Internal — Relay Logic
1044
+ // ================================================================
1045
+ maybeRelay(packet, source) {
1046
+ // Check if the router thinks we should relay
1047
+ if (!this.router.shouldRelay(packet)) {
1048
+ return;
1049
+ }
1050
+ // Check reciprocity: should we relay for this peer?
1051
+ if (!this.reciprocityLedger.shouldRelay(source)) {
1052
+ return;
1053
+ }
1054
+ // Decrement TTL and forward
1055
+ const forwarded = this.router.decrementTTL(packet);
1056
+ // Track reciprocity: we are relaying for this peer
1057
+ this.reciprocityLedger.recordRelayedForPeer(source, packet.encryptedPayload.length);
1058
+ // Try to find a next hop
1059
+ const nextHop = this.router.getNextHop(packet.destHash);
1060
+ if (nextHop) {
1061
+ this.negotiator.send(forwarded, nextHop).catch(() => {
1062
+ // If direct send fails, try store-and-forward
1063
+ this.relayManager.storeForDelivery(packet.destHash, packet.encryptedPayload, this.config.config?.storeTTL ?? 72);
1064
+ });
1065
+ }
1066
+ else {
1067
+ // No route known — store for later delivery
1068
+ this.relayManager.storeForDelivery(packet.destHash, packet.encryptedPayload, this.config.config?.storeTTL ?? 72);
1069
+ }
1070
+ }
1071
+ // ================================================================
1072
+ // Internal — Session Management (X3DH + Double Ratchet)
1073
+ // ================================================================
1074
+ async ensureSession(recipientId) {
1075
+ if (this.sessions.has(recipientId)) {
1076
+ return;
1077
+ }
1078
+ // Check if we have a pre-key bundle for this peer
1079
+ const bundle = this.peerPreKeyBundles.get(recipientId);
1080
+ if (bundle) {
1081
+ await this.initiateHandshake(recipientId, bundle);
1082
+ return;
1083
+ }
1084
+ // No bundle available — request one via route discovery
1085
+ throw new Error(`No pre-key bundle for ${recipientId}. ` +
1086
+ `Use acceptContact() or acceptContact(scannedQR) to establish first contact.`);
1087
+ }
1088
+ async initiateHandshake(peerId, bundle) {
1089
+ // Perform X3DH as the initiator
1090
+ const aliceIdentity = {
1091
+ publicKey: this.identity.getEdPublicKey(),
1092
+ privateKey: this.identity['edPrivateKey'],
1093
+ };
1094
+ const result = initiateKeyExchange(aliceIdentity, bundle);
1095
+ // Initialize the Double Ratchet as sender
1096
+ const ratchetState = initSender(result.sharedSecret, bundle.signedPreKey);
1097
+ this.sessions.set(peerId, ratchetState);
1098
+ this.persistSession(peerId, ratchetState).catch(() => { });
1099
+ // Send handshake packet to the peer so they can complete their side
1100
+ const handshakeEnvelope = {
1101
+ type: 'x3dh_init',
1102
+ senderId: this.getLocalPeerId(),
1103
+ ephemeralPublicKey: Array.from(result.ephemeralPublicKey),
1104
+ identityKey: Array.from(this.identity.getEdPublicKey()),
1105
+ };
1106
+ const envelopeBytes = new TextEncoder().encode(JSON.stringify(handshakeEnvelope));
1107
+ const recipientPublicKey = this.peerCache.getPeerPublicKey(peerId);
1108
+ if (!recipientPublicKey)
1109
+ return;
1110
+ const destHash = deriveDestHash(recipientPublicKey, getCurrentEpochHour());
1111
+ const senderEphId = this.identity.generateEphemeralId();
1112
+ const handshakePacket = createHandshakePacket(destHash, senderEphId, envelopeBytes);
1113
+ await this.routeAndSend(handshakePacket, peerId);
1114
+ }
1115
+ completeIncomingHandshake(envelope) {
1116
+ if (!envelope.ephemeralPublicKey || !envelope.identityKey)
1117
+ return;
1118
+ const aliceEphemeralKey = new Uint8Array(envelope.ephemeralPublicKey);
1119
+ const aliceIdentityKey = new Uint8Array(envelope.identityKey);
1120
+ // Use the stored signed pre-key pair. Falls back to the X25519 identity key
1121
+ // pair only if no bundle has been generated yet (should not happen in practice
1122
+ // since generatePreKeyBundle is called on start()).
1123
+ const bobSignedPreKey = this.signedPreKeyPair ?? {
1124
+ publicKey: this.identity.getPublicKey(),
1125
+ privateKey: this.identity.getPrivateKey(),
1126
+ };
1127
+ const bobIdentity = {
1128
+ publicKey: this.identity.getEdPublicKey(),
1129
+ privateKey: this.identity['edPrivateKey'],
1130
+ };
1131
+ const sharedSecret = completeKeyExchange(bobIdentity, bobSignedPreKey, null, // No one-time pre-key for now
1132
+ aliceIdentityKey, aliceEphemeralKey);
1133
+ // Initialize the Double Ratchet as receiver
1134
+ const ratchetState = initReceiver(sharedSecret, bobSignedPreKey);
1135
+ this.sessions.set(envelope.senderId, ratchetState);
1136
+ this.persistSession(envelope.senderId, ratchetState).catch(() => { });
1137
+ // Cache and persist the peer's public key
1138
+ this.peerCache.addPeer(envelope.senderId, new Uint8Array(envelope.identityKey));
1139
+ this.storage?.set(`peers/${envelope.senderId}`, uint8ArrayToHex(new Uint8Array(envelope.identityKey))).catch(() => { });
1140
+ // Register the peer as a contact
1141
+ this.permissionManager.addContact(envelope.senderId);
1142
+ this.storage?.set('contacts', JSON.stringify(this.permissionManager.getContacts()))
1143
+ .catch(() => { });
1144
+ // Send handshake response
1145
+ const response = {
1146
+ type: 'x3dh_response',
1147
+ senderId: this.getLocalPeerId(),
1148
+ };
1149
+ const responseBytes = new TextEncoder().encode(JSON.stringify(response));
1150
+ const peerPublicKey = this.peerCache.getPeerPublicKey(envelope.senderId);
1151
+ if (!peerPublicKey)
1152
+ return;
1153
+ const destHash = deriveDestHash(peerPublicKey, getCurrentEpochHour());
1154
+ const senderEphId = this.identity.generateEphemeralId();
1155
+ const responsePacket = createHandshakePacket(destHash, senderEphId, responseBytes);
1156
+ this.routeAndSend(responsePacket, envelope.senderId).catch(() => {
1157
+ // Best effort
1158
+ });
1159
+ }
1160
+ // ================================================================
1161
+ // Internal — Routing & Sending
1162
+ // ================================================================
1163
+ async routeAndSend(packet, recipientId) {
1164
+ // Try direct send via the negotiator
1165
+ try {
1166
+ await this.negotiator.send(packet, recipientId);
1167
+ return;
1168
+ }
1169
+ catch {
1170
+ // Direct send failed — try routing
1171
+ }
1172
+ // Try next hop via the social graph router
1173
+ const nextHop = this.router.getNextHop(packet.destHash);
1174
+ if (nextHop) {
1175
+ try {
1176
+ await this.negotiator.send(packet, nextHop);
1177
+ return;
1178
+ }
1179
+ catch {
1180
+ // Next hop also failed
1181
+ }
1182
+ }
1183
+ // Fall back to store-and-forward
1184
+ this.relayManager.storeForDelivery(packet.destHash, packet.encryptedPayload, this.config.config?.storeTTL ?? 72);
1185
+ }
1186
+ // ================================================================
1187
+ // Internal — Presence
1188
+ // ================================================================
1189
+ updatePresence(peerId, status) {
1190
+ const now = Date.now();
1191
+ const previous = this.presenceRecords.get(peerId);
1192
+ const previousStatus = previous?.status ?? 'unknown';
1193
+ this.presenceRecords.set(peerId, {
1194
+ peerId,
1195
+ status,
1196
+ lastSeen: now,
1197
+ });
1198
+ // Deliver any stored blobs for this peer coming online
1199
+ const peerPublicKey = this.peerCache.getPeerPublicKey(peerId);
1200
+ if (peerPublicKey) {
1201
+ const destHash = deriveDestHash(peerPublicKey, getCurrentEpochHour());
1202
+ const storedBlobs = this.relayManager.deliverStored(destHash);
1203
+ for (const blob of storedBlobs) {
1204
+ // Re-inject stored blobs as incoming packets
1205
+ const storedPacket = {
1206
+ version: PROTOCOL_VERSION,
1207
+ flags: PacketFlags.DATA,
1208
+ destHash: blob.destHash,
1209
+ senderEphemeralId: new Uint8Array(16), // Unknown sender eph ID
1210
+ ttl: 0,
1211
+ payloadLength: blob.encryptedPayload.length,
1212
+ encryptedPayload: blob.encryptedPayload,
1213
+ };
1214
+ this.negotiator.send(storedPacket, peerId).catch(() => {
1215
+ // Best effort
1216
+ });
1217
+ }
1218
+ }
1219
+ // Notify the app if status changed
1220
+ if (status !== previousStatus && this.onPresenceHandler) {
1221
+ this.onPresenceHandler(peerId, status);
1222
+ }
1223
+ }
1224
+ // ================================================================
1225
+ // Public API — Message History
1226
+ // ================================================================
1227
+ /**
1228
+ * Returns stored messages for a conversation, most recent last.
1229
+ * Requires a `storage` backend in the config; returns [] without one.
1230
+ *
1231
+ * ```ts
1232
+ * const messages = await MeshWhisper.getMessages(peerId, { limit: 50 });
1233
+ * ```
1234
+ */
1235
+ static async getMessages(peerId, options) {
1236
+ return MeshWhisper.instance.getMessagesInstance(peerId, options);
1237
+ }
1238
+ async getMessagesInstance(peerId, options) {
1239
+ if (!this.storage)
1240
+ return [];
1241
+ const raw = await this.storage.get(`messages/${peerId}`);
1242
+ if (!raw)
1243
+ return [];
1244
+ let messages = JSON.parse(raw);
1245
+ if (options?.before !== undefined) {
1246
+ messages = messages.filter((m) => m.timestamp < options.before);
1247
+ }
1248
+ if (options?.limit !== undefined) {
1249
+ messages = messages.slice(-options.limit);
1250
+ }
1251
+ return messages;
1252
+ }
1253
+ /**
1254
+ * Mark a received message as read and send a read receipt to the sender.
1255
+ * Call this when the user views the conversation.
1256
+ *
1257
+ * ```ts
1258
+ * await MeshWhisper.markRead(message.id, message.senderId);
1259
+ * ```
1260
+ */
1261
+ static async markRead(messageId, peerId) {
1262
+ return MeshWhisper.instance.markReadInstance(messageId, peerId);
1263
+ }
1264
+ async markReadInstance(messageId, peerId) {
1265
+ this.assertRunning();
1266
+ await this.updateMessageStatus(messageId, peerId, 'read');
1267
+ this.sendControl(peerId, { __mw_ctrl: 'read', messageId });
1268
+ }
1269
+ // ================================================================
1270
+ // Internal — Persistence helpers
1271
+ // ================================================================
1272
+ async loadPersistedState() {
1273
+ if (!this.storage)
1274
+ return;
1275
+ // Sessions
1276
+ const sessionKeys = await this.storage.keys('sessions/');
1277
+ for (const key of sessionKeys) {
1278
+ const data = await this.storage.get(key);
1279
+ if (!data)
1280
+ continue;
1281
+ const peerId = key.replace(/^sessions\//, '');
1282
+ try {
1283
+ this.sessions.set(peerId, deserializeRatchetState(data));
1284
+ }
1285
+ catch {
1286
+ // Corrupted session — skip and let re-establishment handle it
1287
+ }
1288
+ }
1289
+ // Peer public keys
1290
+ const peerKeys = await this.storage.keys('peers/');
1291
+ for (const key of peerKeys) {
1292
+ const hex = await this.storage.get(key);
1293
+ if (!hex)
1294
+ continue;
1295
+ const peerId = key.replace(/^peers\//, '');
1296
+ this.peerCache.addPeer(peerId, hexToUint8Array(hex));
1297
+ }
1298
+ // Peer prekey bundles (needed for session re-establishment)
1299
+ const prekeyKeys = await this.storage.keys('prekeys/');
1300
+ for (const key of prekeyKeys) {
1301
+ const b64 = await this.storage.get(key);
1302
+ if (!b64)
1303
+ continue;
1304
+ const peerId = key.replace(/^prekeys\//, '');
1305
+ try {
1306
+ const bundle = deserializePreKeyBundle(base64ToUint8Array(b64));
1307
+ this.peerPreKeyBundles.set(peerId, bundle);
1308
+ }
1309
+ catch {
1310
+ // Corrupted bundle — skip
1311
+ }
1312
+ }
1313
+ // Contacts
1314
+ const contactsRaw = await this.storage.get('contacts');
1315
+ if (contactsRaw) {
1316
+ this.permissionManager.loadContacts(JSON.parse(contactsRaw));
1317
+ }
1318
+ // Seen message IDs (deduplication — rolling 24h window)
1319
+ const seenRaw = await this.storage.get('seen_ids');
1320
+ if (seenRaw) {
1321
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1322
+ const entries = JSON.parse(seenRaw);
1323
+ for (const [id, ts] of entries) {
1324
+ if (ts > cutoff)
1325
+ this.seenMessageIds.set(id, ts);
1326
+ }
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Re-initiates X3DH sessions with all contacts whose prekey bundles are saved.
1331
+ * Called on startup when session state is missing but contacts exist — handles
1332
+ * storage wipe and new-device-with-same-identity-key scenarios.
1333
+ */
1334
+ async reinitiateSessionsOnStartup() {
1335
+ const contacts = this.permissionManager.getContacts();
1336
+ for (const contactId of contacts) {
1337
+ const bundle = this.peerPreKeyBundles.get(contactId);
1338
+ if (!bundle)
1339
+ continue; // no saved bundle — will recover when they next send us a message
1340
+ try {
1341
+ await this.initiateHandshake(contactId, bundle);
1342
+ }
1343
+ catch {
1344
+ // Best effort — peer may be offline, session will establish when they reconnect
1345
+ }
1346
+ }
1347
+ }
1348
+ async persistSession(peerId, state) {
1349
+ await this.storage?.set(`sessions/${peerId}`, serializeRatchetState(state));
1350
+ }
1351
+ async persistPreKeyBundle(peerId, bundle) {
1352
+ await this.storage?.set(`prekeys/${peerId}`, uint8ArrayToBase64(serializePreKeyBundle(bundle)));
1353
+ }
1354
+ async persistContacts() {
1355
+ await this.storage?.set('contacts', JSON.stringify(this.permissionManager.getContacts()));
1356
+ }
1357
+ async persistPeers() {
1358
+ if (!this.storage)
1359
+ return;
1360
+ // Save any peers not yet written (incremental saves happen in completeIncomingHandshake)
1361
+ // This is a belt-and-suspenders flush on shutdown
1362
+ for (const [peerId, pubKey] of this.peerCache.peers) {
1363
+ await this.storage.set(`peers/${peerId}`, uint8ArrayToHex(pubKey));
1364
+ }
1365
+ }
1366
+ async persistSeenIds() {
1367
+ if (!this.storage)
1368
+ return;
1369
+ const entries = [...this.seenMessageIds.entries()];
1370
+ await this.storage.set('seen_ids', JSON.stringify(entries));
1371
+ }
1372
+ pruneSeenIds() {
1373
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1374
+ for (const [id, ts] of this.seenMessageIds) {
1375
+ if (ts < cutoff)
1376
+ this.seenMessageIds.delete(id);
1377
+ }
1378
+ }
1379
+ async saveMessage(message) {
1380
+ if (!this.storage)
1381
+ return;
1382
+ const key = `messages/${message.conversationId}`;
1383
+ const raw = await this.storage.get(key);
1384
+ const messages = raw ? JSON.parse(raw) : [];
1385
+ const existing = messages.findIndex((m) => m.id === message.id);
1386
+ if (existing >= 0) {
1387
+ messages[existing] = message; // update (e.g. status change)
1388
+ }
1389
+ else {
1390
+ messages.push(message);
1391
+ }
1392
+ await this.storage.set(key, JSON.stringify(messages));
1393
+ }
1394
+ async updateMessageStatus(messageId, conversationId, status) {
1395
+ if (!this.storage)
1396
+ return;
1397
+ const key = `messages/${conversationId}`;
1398
+ const raw = await this.storage.get(key);
1399
+ if (!raw)
1400
+ return;
1401
+ const messages = JSON.parse(raw);
1402
+ const msg = messages.find((m) => m.id === messageId);
1403
+ if (!msg)
1404
+ return;
1405
+ msg.status = status;
1406
+ await this.storage.set(key, JSON.stringify(messages));
1407
+ if (this.config.onMessageStatus) {
1408
+ this.config.onMessageStatus(messageId, status);
1409
+ }
1410
+ }
1411
+ // ================================================================
1412
+ // Internal — Control messages (delivery receipts, typing, etc.)
1413
+ // ================================================================
1414
+ sendControl(peerId, payload) {
1415
+ const bytes = new TextEncoder().encode(JSON.stringify(payload));
1416
+ this.sendMessage(peerId, bytes, { urgency: 'background' }).catch(() => { });
1417
+ }
1418
+ handleControlMessage(ctrl, fromPeerId) {
1419
+ switch (ctrl.__mw_ctrl) {
1420
+ case 'delivered':
1421
+ if (ctrl.messageId) {
1422
+ this.updateMessageStatus(ctrl.messageId, fromPeerId, 'delivered').catch(() => { });
1423
+ }
1424
+ break;
1425
+ case 'read':
1426
+ if (ctrl.messageId) {
1427
+ this.updateMessageStatus(ctrl.messageId, fromPeerId, 'read').catch(() => { });
1428
+ }
1429
+ break;
1430
+ }
1431
+ }
1432
+ // ================================================================
1433
+ // Internal — Assertions
1434
+ // ================================================================
1435
+ assertRunning() {
1436
+ if (!this.running) {
1437
+ throw new Error('MeshWhisper is not running. Call MeshWhisper.init() first.');
1438
+ }
1439
+ }
1440
+ }
1441
+ function tryParseControl(payload) {
1442
+ try {
1443
+ const text = new TextDecoder().decode(payload);
1444
+ if (!text.startsWith('{'))
1445
+ return null;
1446
+ const obj = JSON.parse(text);
1447
+ if (typeof obj.__mw_ctrl !== 'string')
1448
+ return null;
1449
+ return obj;
1450
+ }
1451
+ catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+ function isControlPayload(payload) {
1456
+ return tryParseControl(payload) !== null;
1457
+ }
1458
+ // ============================================================
1459
+ // Utility Functions
1460
+ // ============================================================
1461
+ function uint8ArrayToHex(arr) {
1462
+ let hex = '';
1463
+ for (let i = 0; i < arr.length; i++) {
1464
+ hex += arr[i].toString(16).padStart(2, '0');
1465
+ }
1466
+ return hex;
1467
+ }
1468
+ function hexToUint8Array(hex) {
1469
+ const len = hex.length >>> 1;
1470
+ const arr = new Uint8Array(len);
1471
+ for (let i = 0; i < len; i++) {
1472
+ arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
1473
+ }
1474
+ return arr;
1475
+ }
1476
+ function uint8ArrayToBase64(arr) {
1477
+ // Works in both Node.js and browser environments
1478
+ if (typeof Buffer !== 'undefined') {
1479
+ return Buffer.from(arr).toString('base64');
1480
+ }
1481
+ let binary = '';
1482
+ for (let i = 0; i < arr.length; i++) {
1483
+ binary += String.fromCharCode(arr[i]);
1484
+ }
1485
+ return btoa(binary);
1486
+ }
1487
+ function base64ToUint8Array(b64) {
1488
+ if (typeof Buffer !== 'undefined') {
1489
+ return new Uint8Array(Buffer.from(b64, 'base64'));
1490
+ }
1491
+ const binary = atob(b64);
1492
+ const bytes = new Uint8Array(binary.length);
1493
+ for (let i = 0; i < binary.length; i++) {
1494
+ bytes[i] = binary.charCodeAt(i);
1495
+ }
1496
+ return bytes;
1497
+ }
1498
+ function generateMessageId() {
1499
+ const bytes = randomBytes(16);
1500
+ return uint8ArrayToHex(bytes);
1501
+ }
1502
+ // ============================================================
1503
+ // Ratchet Header Serialization
1504
+ //
1505
+ // Wire format:
1506
+ // [32 bytes dhPublicKey] [4 bytes previousChainLength BE] [4 bytes messageNumber BE]
1507
+ // ============================================================
1508
+ const RATCHET_HEADER_SIZE = 40;
1509
+ function serializeRatchetHeader(header) {
1510
+ const buf = new Uint8Array(RATCHET_HEADER_SIZE);
1511
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
1512
+ buf.set(header.dhPublicKey, 0);
1513
+ view.setUint32(32, header.previousChainLength, false);
1514
+ view.setUint32(36, header.messageNumber, false);
1515
+ return buf;
1516
+ }
1517
+ function deserializeRatchetHeader(data) {
1518
+ if (data.length < RATCHET_HEADER_SIZE) {
1519
+ throw new Error('Data too short for ratchet header');
1520
+ }
1521
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1522
+ const header = {
1523
+ dhPublicKey: data.slice(0, 32),
1524
+ previousChainLength: view.getUint32(32, false),
1525
+ messageNumber: view.getUint32(36, false),
1526
+ };
1527
+ const ciphertextBody = data.slice(RATCHET_HEADER_SIZE);
1528
+ return { header, ciphertextBody };
1529
+ }
1530
+ //# sourceMappingURL=index.js.map