@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.
- package/README.md +138 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +19 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/chaff/index.d.ts +91 -0
- package/dist/chaff/index.d.ts.map +1 -0
- package/dist/chaff/index.js +268 -0
- package/dist/chaff/index.js.map +1 -0
- package/dist/cluster/index.d.ts +159 -0
- package/dist/cluster/index.d.ts.map +1 -0
- package/dist/cluster/index.js +393 -0
- package/dist/cluster/index.js.map +1 -0
- package/dist/compliance/index.d.ts +129 -0
- package/dist/compliance/index.d.ts.map +1 -0
- package/dist/compliance/index.js +315 -0
- package/dist/compliance/index.js.map +1 -0
- package/dist/crypto/index.d.ts +65 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +146 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/group/index.d.ts +155 -0
- package/dist/group/index.d.ts.map +1 -0
- package/dist/group/index.js +560 -0
- package/dist/group/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/namespace/index.d.ts +155 -0
- package/dist/namespace/index.d.ts.map +1 -0
- package/dist/namespace/index.js +278 -0
- package/dist/namespace/index.js.map +1 -0
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +19 -0
- package/dist/node/index.js.map +1 -0
- package/dist/packet/index.d.ts +63 -0
- package/dist/packet/index.d.ts.map +1 -0
- package/dist/packet/index.js +244 -0
- package/dist/packet/index.js.map +1 -0
- package/dist/permissions/index.d.ts +107 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +282 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/persistence/idb-storage.d.ts +27 -0
- package/dist/persistence/idb-storage.d.ts.map +1 -0
- package/dist/persistence/idb-storage.js +75 -0
- package/dist/persistence/idb-storage.js.map +1 -0
- package/dist/persistence/index.d.ts +4 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +3 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/node-storage.d.ts +33 -0
- package/dist/persistence/node-storage.d.ts.map +1 -0
- package/dist/persistence/node-storage.js +90 -0
- package/dist/persistence/node-storage.js.map +1 -0
- package/dist/persistence/serialization.d.ts +4 -0
- package/dist/persistence/serialization.d.ts.map +1 -0
- package/dist/persistence/serialization.js +49 -0
- package/dist/persistence/serialization.js.map +1 -0
- package/dist/persistence/types.d.ts +29 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/persistence/types.js +5 -0
- package/dist/persistence/types.js.map +1 -0
- package/dist/ratchet/index.d.ts +80 -0
- package/dist/ratchet/index.d.ts.map +1 -0
- package/dist/ratchet/index.js +259 -0
- package/dist/ratchet/index.js.map +1 -0
- package/dist/reciprocity/index.d.ts +109 -0
- package/dist/reciprocity/index.d.ts.map +1 -0
- package/dist/reciprocity/index.js +311 -0
- package/dist/reciprocity/index.js.map +1 -0
- package/dist/relay/index.d.ts +87 -0
- package/dist/relay/index.d.ts.map +1 -0
- package/dist/relay/index.js +286 -0
- package/dist/relay/index.js.map +1 -0
- package/dist/routing/index.d.ts +136 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +478 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/sdk/index.d.ts +322 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +1530 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sybil/index.d.ts +123 -0
- package/dist/sybil/index.d.ts.map +1 -0
- package/dist/sybil/index.js +491 -0
- package/dist/sybil/index.js.map +1 -0
- package/dist/transport/browser/index.d.ts +34 -0
- package/dist/transport/browser/index.d.ts.map +1 -0
- package/dist/transport/browser/index.js +176 -0
- package/dist/transport/browser/index.js.map +1 -0
- package/dist/transport/local/index.d.ts +57 -0
- package/dist/transport/local/index.d.ts.map +1 -0
- package/dist/transport/local/index.js +442 -0
- package/dist/transport/local/index.js.map +1 -0
- package/dist/transport/negotiator/index.d.ts +79 -0
- package/dist/transport/negotiator/index.d.ts.map +1 -0
- package/dist/transport/negotiator/index.js +289 -0
- package/dist/transport/negotiator/index.js.map +1 -0
- package/dist/transport/node/index.d.ts +56 -0
- package/dist/transport/node/index.d.ts.map +1 -0
- package/dist/transport/node/index.js +209 -0
- package/dist/transport/node/index.js.map +1 -0
- package/dist/transport/noop/index.d.ts +11 -0
- package/dist/transport/noop/index.d.ts.map +1 -0
- package/dist/transport/noop/index.js +20 -0
- package/dist/transport/noop/index.js.map +1 -0
- package/dist/transport/p2p/index.d.ts +109 -0
- package/dist/transport/p2p/index.d.ts.map +1 -0
- package/dist/transport/p2p/index.js +237 -0
- package/dist/transport/p2p/index.js.map +1 -0
- package/dist/transport/websocket/index.d.ts +89 -0
- package/dist/transport/websocket/index.d.ts.map +1 -0
- package/dist/transport/websocket/index.js +498 -0
- package/dist/transport/websocket/index.js.map +1 -0
- package/dist/transport/websocket/serialize.d.ts +5 -0
- package/dist/transport/websocket/serialize.d.ts.map +1 -0
- package/dist/transport/websocket/serialize.js +55 -0
- package/dist/transport/websocket/serialize.js.map +1 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/x3dh/index.d.ts +120 -0
- package/dist/x3dh/index.d.ts.map +1 -0
- package/dist/x3dh/index.js +290 -0
- package/dist/x3dh/index.js.map +1 -0
- package/package.json +59 -0
- package/src/browser/index.ts +19 -0
- package/src/chaff/index.ts +340 -0
- package/src/cluster/index.ts +482 -0
- package/src/compliance/index.ts +407 -0
- package/src/crypto/index.ts +193 -0
- package/src/group/index.ts +719 -0
- package/src/index.ts +87 -0
- package/src/lz4js.d.ts +58 -0
- package/src/namespace/index.ts +336 -0
- package/src/node/index.ts +19 -0
- package/src/packet/index.ts +326 -0
- package/src/permissions/index.ts +405 -0
- package/src/persistence/idb-storage.ts +83 -0
- package/src/persistence/index.ts +3 -0
- package/src/persistence/node-storage.ts +96 -0
- package/src/persistence/serialization.ts +75 -0
- package/src/persistence/types.ts +33 -0
- package/src/ratchet/index.ts +363 -0
- package/src/reciprocity/index.ts +371 -0
- package/src/relay/index.ts +382 -0
- package/src/routing/index.ts +577 -0
- package/src/sdk/index.ts +1994 -0
- package/src/sybil/index.ts +661 -0
- package/src/transport/browser/index.ts +201 -0
- package/src/transport/local/index.ts +540 -0
- package/src/transport/negotiator/index.ts +397 -0
- package/src/transport/node/index.ts +234 -0
- package/src/transport/noop/index.ts +22 -0
- package/src/transport/p2p/index.ts +345 -0
- package/src/transport/websocket/index.ts +660 -0
- package/src/transport/websocket/serialize.ts +68 -0
- package/src/types.ts +275 -0
- 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
|