@thezelijah/majik-message 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +67 -0
- package/README.md +265 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +34 -0
- package/dist/core/contacts/majik-contact-directory.js +165 -0
- package/dist/core/contacts/majik-contact.d.ts +53 -0
- package/dist/core/contacts/majik-contact.js +135 -0
- package/dist/core/crypto/constants.d.ts +7 -0
- package/dist/core/crypto/constants.js +6 -0
- package/dist/core/crypto/crypto-provider.d.ts +20 -0
- package/dist/core/crypto/crypto-provider.js +70 -0
- package/dist/core/crypto/encryption-engine.d.ts +59 -0
- package/dist/core/crypto/encryption-engine.js +257 -0
- package/dist/core/crypto/keystore.d.ts +126 -0
- package/dist/core/crypto/keystore.js +575 -0
- package/dist/core/messages/envelope-cache.d.ts +51 -0
- package/dist/core/messages/envelope-cache.js +375 -0
- package/dist/core/messages/message-envelope.d.ts +36 -0
- package/dist/core/messages/message-envelope.js +161 -0
- package/dist/core/scanner/scanner-engine.d.ts +27 -0
- package/dist/core/scanner/scanner-engine.js +120 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/types.js +1 -0
- package/dist/core/utils/APITranscoder.d.ts +114 -0
- package/dist/core/utils/APITranscoder.js +305 -0
- package/dist/core/utils/idb-majik-system.d.ts +15 -0
- package/dist/core/utils/idb-majik-system.js +37 -0
- package/dist/core/utils/majik-file-utils.d.ts +16 -0
- package/dist/core/utils/majik-file-utils.js +153 -0
- package/dist/core/utils/utilities.d.ts +22 -0
- package/dist/core/utils/utilities.js +80 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/majik-message.d.ts +202 -0
- package/dist/majik-message.js +940 -0
- package/package.json +97 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { arrayBufferToBase64, base64ToArrayBuffer } from "../utils/utilities";
|
|
2
|
+
import { MessageEnvelope } from "./message-envelope";
|
|
3
|
+
/* -------------------------------
|
|
4
|
+
* Errors
|
|
5
|
+
* ------------------------------- */
|
|
6
|
+
export class EnvelopeCacheError extends Error {
|
|
7
|
+
cause;
|
|
8
|
+
constructor(message, cause) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "EnvelopeCacheError";
|
|
11
|
+
this.cause = cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/* -------------------------------
|
|
15
|
+
* EnvelopeCache
|
|
16
|
+
* ------------------------------- */
|
|
17
|
+
export class EnvelopeCache {
|
|
18
|
+
dbPromise;
|
|
19
|
+
dbName;
|
|
20
|
+
storeName;
|
|
21
|
+
maxEntries;
|
|
22
|
+
// In-memory cache
|
|
23
|
+
memoryCache;
|
|
24
|
+
memoryCacheSize;
|
|
25
|
+
constructor(config) {
|
|
26
|
+
this.dbName = config?.dbName || "MajikEnvelopeDB";
|
|
27
|
+
this.storeName = config?.storeName || "envelopes";
|
|
28
|
+
this.maxEntries = config?.maxEntries;
|
|
29
|
+
this.memoryCacheSize = config?.memoryCacheSize || 100;
|
|
30
|
+
this.memoryCache = new Map();
|
|
31
|
+
this.dbPromise = this.initDB();
|
|
32
|
+
}
|
|
33
|
+
/* -------------------------------
|
|
34
|
+
* IndexedDB Initialization
|
|
35
|
+
* ------------------------------- */
|
|
36
|
+
initDB() {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const request = indexedDB.open(this.dbName, 1);
|
|
39
|
+
request.onupgradeneeded = (event) => {
|
|
40
|
+
const db = event.target.result;
|
|
41
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
42
|
+
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
|
|
43
|
+
store.createIndex("timestamp", "timestamp");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
request.onsuccess = () => resolve(request.result);
|
|
47
|
+
request.onerror = () => reject(new EnvelopeCacheError("Failed to open IndexedDB", request.error));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/* -------------------------------
|
|
51
|
+
* Generate unique ID for envelope (SHA-256)
|
|
52
|
+
* ------------------------------- */
|
|
53
|
+
async getEnvelopeId(envelope) {
|
|
54
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", envelope.encryptedBlob);
|
|
55
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
56
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
57
|
+
}
|
|
58
|
+
/* -------------------------------
|
|
59
|
+
* Save envelope to cache (IDB + memory)
|
|
60
|
+
* -------------------------------
|
|
61
|
+
*/
|
|
62
|
+
async set(envelope, source) {
|
|
63
|
+
const id = await this.getEnvelopeId(envelope);
|
|
64
|
+
try {
|
|
65
|
+
// Add to memory cache
|
|
66
|
+
this.memoryCache.set(id, envelope);
|
|
67
|
+
if (this.memoryCache.size > this.memoryCacheSize) {
|
|
68
|
+
// Remove oldest entry
|
|
69
|
+
const firstKey = this.memoryCache.keys().next().value;
|
|
70
|
+
if (firstKey !== undefined) {
|
|
71
|
+
this.memoryCache.delete(firstKey);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Persist to IndexedDB
|
|
75
|
+
const db = await this.dbPromise;
|
|
76
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
77
|
+
const store = tx.objectStore(this.storeName);
|
|
78
|
+
const timestamp = Date.now();
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
const req = store.put({ id, envelope, timestamp, source });
|
|
81
|
+
req.onsuccess = () => resolve();
|
|
82
|
+
req.onerror = () => reject(new EnvelopeCacheError("Failed to save envelope", req.error));
|
|
83
|
+
});
|
|
84
|
+
// Optional: enforce maxEntries in IDB
|
|
85
|
+
if (this.maxEntries)
|
|
86
|
+
await this.enforceMaxEntries();
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error("EnvelopeCache set error:", err);
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/* -------------------------------
|
|
94
|
+
* List recent envelopes with pagination (most recent first)
|
|
95
|
+
* Returns array of objects: { id, envelope, timestamp, source }
|
|
96
|
+
* -------------------------------
|
|
97
|
+
*/
|
|
98
|
+
async listRecent(offset = 0, limit = 50) {
|
|
99
|
+
const results = [];
|
|
100
|
+
try {
|
|
101
|
+
const db = await this.dbPromise;
|
|
102
|
+
const tx = db.transaction(this.storeName, "readonly");
|
|
103
|
+
const store = tx.objectStore(this.storeName);
|
|
104
|
+
const index = store.index("timestamp");
|
|
105
|
+
// Iterate newest first
|
|
106
|
+
const req = index.openCursor(null, "prev");
|
|
107
|
+
let skipped = 0;
|
|
108
|
+
await new Promise((resolve, reject) => {
|
|
109
|
+
req.onsuccess = (event) => {
|
|
110
|
+
const cursor = event.target
|
|
111
|
+
.result;
|
|
112
|
+
if (!cursor)
|
|
113
|
+
return resolve();
|
|
114
|
+
if (skipped < offset) {
|
|
115
|
+
skipped++;
|
|
116
|
+
cursor.continue();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (results.length < limit) {
|
|
120
|
+
const val = cursor.value;
|
|
121
|
+
const envelope = new MessageEnvelope(val.envelope.encryptedBlob); // wrap here
|
|
122
|
+
results.push({
|
|
123
|
+
id: val.id,
|
|
124
|
+
envelope: envelope,
|
|
125
|
+
timestamp: val.timestamp,
|
|
126
|
+
source: val.source,
|
|
127
|
+
});
|
|
128
|
+
cursor.continue();
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
resolve();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
req.onerror = () => reject(new EnvelopeCacheError("Failed to list recent envelopes", req.error));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error("EnvelopeCache listRecent error:", err);
|
|
139
|
+
}
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
/* -------------------------------
|
|
143
|
+
* Get envelope (checks memory first)
|
|
144
|
+
* ------------------------------- */
|
|
145
|
+
async get(envelope) {
|
|
146
|
+
const id = await this.getEnvelopeId(envelope);
|
|
147
|
+
// 1️⃣ Try memory cache
|
|
148
|
+
if (this.memoryCache.has(id))
|
|
149
|
+
return this.memoryCache.get(id);
|
|
150
|
+
// 2️⃣ Fallback to IndexedDB
|
|
151
|
+
const dbEnvelope = await this.getById(id);
|
|
152
|
+
if (dbEnvelope) {
|
|
153
|
+
// Populate memory cache
|
|
154
|
+
this.memoryCache.set(id, dbEnvelope);
|
|
155
|
+
if (this.memoryCache.size > this.memoryCacheSize) {
|
|
156
|
+
const firstKey = this.memoryCache.keys().next().value;
|
|
157
|
+
if (firstKey !== undefined) {
|
|
158
|
+
this.memoryCache.delete(firstKey);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return dbEnvelope;
|
|
163
|
+
}
|
|
164
|
+
/* -------------------------------
|
|
165
|
+
* Get envelope by ID directly
|
|
166
|
+
* ------------------------------- */
|
|
167
|
+
async getById(id) {
|
|
168
|
+
try {
|
|
169
|
+
const db = await this.dbPromise;
|
|
170
|
+
const tx = db.transaction(this.storeName, "readonly");
|
|
171
|
+
const store = tx.objectStore(this.storeName);
|
|
172
|
+
return await new Promise((resolve, reject) => {
|
|
173
|
+
const req = store.get(id);
|
|
174
|
+
req.onsuccess = () => resolve(req.result?.envelope);
|
|
175
|
+
req.onerror = () => reject(new EnvelopeCacheError("Failed to get envelope", req.error));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
console.error("EnvelopeCache getById error:", err);
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/* -------------------------------
|
|
184
|
+
* Check if envelope exists
|
|
185
|
+
* ------------------------------- */
|
|
186
|
+
async has(envelope) {
|
|
187
|
+
const id = await this.getEnvelopeId(envelope);
|
|
188
|
+
if (this.memoryCache.has(id))
|
|
189
|
+
return true;
|
|
190
|
+
try {
|
|
191
|
+
const db = await this.dbPromise;
|
|
192
|
+
const tx = db.transaction(this.storeName, "readonly");
|
|
193
|
+
const store = tx.objectStore(this.storeName);
|
|
194
|
+
return await new Promise((resolve) => {
|
|
195
|
+
const req = store.getKey(id);
|
|
196
|
+
req.onsuccess = () => resolve(req.result !== undefined);
|
|
197
|
+
req.onerror = () => resolve(false);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
console.error("EnvelopeCache has error:", err);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/* -------------------------------
|
|
206
|
+
* Delete envelope (memory + IDB)
|
|
207
|
+
* ------------------------------- */
|
|
208
|
+
async delete(envelope) {
|
|
209
|
+
const id = await this.getEnvelopeId(envelope);
|
|
210
|
+
// Remove from memory
|
|
211
|
+
this.memoryCache.delete(id);
|
|
212
|
+
try {
|
|
213
|
+
const db = await this.dbPromise;
|
|
214
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
215
|
+
const store = tx.objectStore(this.storeName);
|
|
216
|
+
await new Promise((resolve, reject) => {
|
|
217
|
+
const req = store.delete(id);
|
|
218
|
+
req.onsuccess = () => resolve();
|
|
219
|
+
req.onerror = () => reject(new EnvelopeCacheError("Failed to delete envelope", req.error));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.error("EnvelopeCache delete error:", err);
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/* -------------------------------
|
|
228
|
+
* Delete all envelopes by fingerprint
|
|
229
|
+
* ------------------------------- */
|
|
230
|
+
async deleteByFingerprint(fingerprint) {
|
|
231
|
+
try {
|
|
232
|
+
const db = await this.dbPromise;
|
|
233
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
234
|
+
const store = tx.objectStore(this.storeName);
|
|
235
|
+
// First, collect all IDs to delete
|
|
236
|
+
const idsToDelete = [];
|
|
237
|
+
const cursorReq = store.openCursor();
|
|
238
|
+
await new Promise((resolve, reject) => {
|
|
239
|
+
cursorReq.onsuccess = (event) => {
|
|
240
|
+
const cursor = event.target
|
|
241
|
+
.result;
|
|
242
|
+
if (!cursor)
|
|
243
|
+
return resolve();
|
|
244
|
+
const val = cursor.value;
|
|
245
|
+
const env = new MessageEnvelope(val.envelope.encryptedBlob);
|
|
246
|
+
const envelopeFingerprint = env.extractFingerprint();
|
|
247
|
+
if (envelopeFingerprint === fingerprint) {
|
|
248
|
+
idsToDelete.push(val.id);
|
|
249
|
+
}
|
|
250
|
+
cursor.continue();
|
|
251
|
+
};
|
|
252
|
+
cursorReq.onerror = () => reject(new EnvelopeCacheError("Failed to iterate envelopes", cursorReq.error));
|
|
253
|
+
});
|
|
254
|
+
// Delete from IndexedDB and memory cache
|
|
255
|
+
for (const id of idsToDelete) {
|
|
256
|
+
await new Promise((resolve, reject) => {
|
|
257
|
+
const req = store.delete(id);
|
|
258
|
+
req.onsuccess = () => resolve();
|
|
259
|
+
req.onerror = () => reject(new EnvelopeCacheError("Failed to delete envelope", req.error));
|
|
260
|
+
});
|
|
261
|
+
// Remove from memory cache
|
|
262
|
+
this.memoryCache.delete(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
console.error("EnvelopeCache deleteByFingerprint error:", err);
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/* -------------------------------
|
|
271
|
+
* Clear all envelopes (memory + IDB)
|
|
272
|
+
* ------------------------------- */
|
|
273
|
+
async clear() {
|
|
274
|
+
// Clear memory cache
|
|
275
|
+
this.memoryCache.clear();
|
|
276
|
+
try {
|
|
277
|
+
const db = await this.dbPromise;
|
|
278
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
279
|
+
const store = tx.objectStore(this.storeName);
|
|
280
|
+
await new Promise((resolve, reject) => {
|
|
281
|
+
const req = store.clear();
|
|
282
|
+
req.onsuccess = () => resolve();
|
|
283
|
+
req.onerror = () => reject(new EnvelopeCacheError("Failed to clear envelope cache", req.error));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
console.error("EnvelopeCache clear error:", err);
|
|
288
|
+
return {
|
|
289
|
+
message: "Failed to clear envelope cache",
|
|
290
|
+
success: false,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
success: true,
|
|
295
|
+
message: "Envelope cache cleared successfully",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/* -------------------------------
|
|
299
|
+
* Enforce max entries in IDB (oldest first)
|
|
300
|
+
* ------------------------------- */
|
|
301
|
+
async enforceMaxEntries() {
|
|
302
|
+
if (!this.maxEntries)
|
|
303
|
+
return;
|
|
304
|
+
try {
|
|
305
|
+
const db = await this.dbPromise;
|
|
306
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
307
|
+
const store = tx.objectStore(this.storeName);
|
|
308
|
+
const index = store.index("timestamp");
|
|
309
|
+
const countReq = store.count();
|
|
310
|
+
const total = await new Promise((resolve, reject) => {
|
|
311
|
+
countReq.onsuccess = () => resolve(countReq.result);
|
|
312
|
+
countReq.onerror = () => reject(countReq.error);
|
|
313
|
+
});
|
|
314
|
+
if (total <= this.maxEntries)
|
|
315
|
+
return;
|
|
316
|
+
const toDelete = total - this.maxEntries;
|
|
317
|
+
const keys = [];
|
|
318
|
+
const cursorReq = index.openKeyCursor();
|
|
319
|
+
await new Promise((resolve, reject) => {
|
|
320
|
+
cursorReq.onsuccess = (event) => {
|
|
321
|
+
const cursor = event.target
|
|
322
|
+
.result;
|
|
323
|
+
if (cursor && keys.length < toDelete) {
|
|
324
|
+
keys.push(cursor.primaryKey);
|
|
325
|
+
cursor.continue();
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
resolve();
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
cursorReq.onerror = () => reject(cursorReq.error);
|
|
332
|
+
});
|
|
333
|
+
for (const key of keys) {
|
|
334
|
+
await new Promise((resolve, reject) => {
|
|
335
|
+
const req = store.delete(key);
|
|
336
|
+
req.onsuccess = () => resolve();
|
|
337
|
+
req.onerror = () => reject(req.error);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
console.error("EnvelopeCache enforceMaxEntries error:", err);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
toJSON() {
|
|
346
|
+
const items = [];
|
|
347
|
+
this.memoryCache.forEach((envelope, id) => {
|
|
348
|
+
items.push({
|
|
349
|
+
id,
|
|
350
|
+
base64Payload: arrayBufferToBase64(envelope.encryptedBlob),
|
|
351
|
+
timestamp: Date.now(),
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
config: {
|
|
356
|
+
dbName: this.dbName,
|
|
357
|
+
storeName: this.storeName,
|
|
358
|
+
maxEntries: this.maxEntries,
|
|
359
|
+
memoryCacheSize: this.memoryCacheSize,
|
|
360
|
+
},
|
|
361
|
+
items,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/* -------------------------------
|
|
365
|
+
* Deserialize cache from JSON
|
|
366
|
+
* ------------------------------- */
|
|
367
|
+
static fromJSON(json) {
|
|
368
|
+
const cache = new EnvelopeCache(json.config);
|
|
369
|
+
for (const item of json.items) {
|
|
370
|
+
const envelope = new MessageEnvelope(base64ToArrayBuffer(item.base64Payload));
|
|
371
|
+
cache.memoryCache.set(item.id, envelope);
|
|
372
|
+
}
|
|
373
|
+
return cache;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { EnvelopePayload, RecipientKeys, SingleRecipientPayload } from "../types";
|
|
2
|
+
export type EnvelopeErrorCode = "INVALID_INPUT" | "FORMAT_ERROR" | "VALIDATION_ERROR";
|
|
3
|
+
export declare class MessageEnvelopeError extends Error {
|
|
4
|
+
readonly code: EnvelopeErrorCode;
|
|
5
|
+
readonly raw?: string;
|
|
6
|
+
constructor(code: EnvelopeErrorCode, message: string, raw?: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class MessageEnvelope {
|
|
9
|
+
/** Raw decoded encrypted payload */
|
|
10
|
+
readonly encryptedBlob: ArrayBuffer;
|
|
11
|
+
static readonly PREFIX = "~*$MJKMSG";
|
|
12
|
+
static readonly DEFAULT_FINGERPRINT_LENGTH = 32;
|
|
13
|
+
constructor(blob: ArrayBuffer);
|
|
14
|
+
get raw(): ArrayBuffer;
|
|
15
|
+
/** Quick validation without throwing */
|
|
16
|
+
static tryFromString(raw: unknown): {
|
|
17
|
+
envelope?: MessageEnvelope;
|
|
18
|
+
error?: MessageEnvelopeError;
|
|
19
|
+
};
|
|
20
|
+
static isEnvelopeCandidate(input: unknown): boolean;
|
|
21
|
+
getVersion(): number;
|
|
22
|
+
static fromMatchedString(raw: unknown): MessageEnvelope;
|
|
23
|
+
extractFingerprint(fingerprintLength?: number): string;
|
|
24
|
+
extractEncryptedPayload(): EnvelopePayload;
|
|
25
|
+
/**
|
|
26
|
+
* Returns the ephemeral encrypted key for a given fingerprint
|
|
27
|
+
*/
|
|
28
|
+
getRecipientKey(fingerprint: string): RecipientKeys | undefined;
|
|
29
|
+
getSingleRecipientPayload(): SingleRecipientPayload | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Checks if this envelope contains a key for the given fingerprint
|
|
32
|
+
*/
|
|
33
|
+
hasRecipient(fingerprint: string): boolean;
|
|
34
|
+
isGroup(): boolean;
|
|
35
|
+
isSolo(): boolean;
|
|
36
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { base64ToArrayBuffer, arrayBufferToBase64 } from "../utils/utilities";
|
|
2
|
+
/* -------------------------------
|
|
3
|
+
* Constants
|
|
4
|
+
* ------------------------------- */
|
|
5
|
+
const ENVELOPE_PREFIX = "~*$MJKMSG";
|
|
6
|
+
const ENVELOPE_REGEX = /^~\*\$MJKMSG:([A-Za-z0-9+/=]+)$/;
|
|
7
|
+
const MAX_ENVELOPE_LENGTH = 16_384; // raw string
|
|
8
|
+
const MAX_PAYLOAD_BYTES = 12_288; // decoded binary
|
|
9
|
+
export class MessageEnvelopeError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
raw;
|
|
12
|
+
constructor(code, message, raw) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "MessageEnvelopeError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.raw = raw;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/* -------------------------------
|
|
20
|
+
* MessageEnvelope
|
|
21
|
+
* ------------------------------- */
|
|
22
|
+
export class MessageEnvelope {
|
|
23
|
+
/** Raw decoded encrypted payload */
|
|
24
|
+
encryptedBlob;
|
|
25
|
+
static PREFIX = ENVELOPE_PREFIX;
|
|
26
|
+
static DEFAULT_FINGERPRINT_LENGTH = 32;
|
|
27
|
+
constructor(blob) {
|
|
28
|
+
this.encryptedBlob = blob;
|
|
29
|
+
}
|
|
30
|
+
get raw() {
|
|
31
|
+
return this.encryptedBlob;
|
|
32
|
+
}
|
|
33
|
+
/** Quick validation without throwing */
|
|
34
|
+
static tryFromString(raw) {
|
|
35
|
+
try {
|
|
36
|
+
return { envelope: MessageEnvelope.fromMatchedString(raw) };
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return { error: err };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
static isEnvelopeCandidate(input) {
|
|
43
|
+
return (typeof input === "string" &&
|
|
44
|
+
input.length <= MAX_ENVELOPE_LENGTH &&
|
|
45
|
+
ENVELOPE_REGEX.test(input.trim()));
|
|
46
|
+
}
|
|
47
|
+
getVersion() {
|
|
48
|
+
const view = new Uint8Array(this.encryptedBlob);
|
|
49
|
+
if (view.length < 1) {
|
|
50
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Encrypted blob too short to contain version byte");
|
|
51
|
+
}
|
|
52
|
+
return view[0];
|
|
53
|
+
}
|
|
54
|
+
/* -------------------------------
|
|
55
|
+
* Factory
|
|
56
|
+
* ------------------------------- */
|
|
57
|
+
static fromMatchedString(raw) {
|
|
58
|
+
if (typeof raw !== "string") {
|
|
59
|
+
throw new MessageEnvelopeError("INVALID_INPUT", "Envelope input must be a string");
|
|
60
|
+
}
|
|
61
|
+
const trimmed = raw.trim();
|
|
62
|
+
if (trimmed.length > MAX_ENVELOPE_LENGTH) {
|
|
63
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Envelope exceeds maximum allowed length", raw);
|
|
64
|
+
}
|
|
65
|
+
const match = ENVELOPE_REGEX.exec(trimmed);
|
|
66
|
+
if (!match) {
|
|
67
|
+
throw new MessageEnvelopeError("FORMAT_ERROR", `Invalid envelope format. Expected ${ENVELOPE_PREFIX}:<base64>`, raw);
|
|
68
|
+
}
|
|
69
|
+
let decoded;
|
|
70
|
+
try {
|
|
71
|
+
decoded = base64ToArrayBuffer(match[1]);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Base64 payload failed to decode", raw);
|
|
75
|
+
}
|
|
76
|
+
if (decoded.byteLength === 0) {
|
|
77
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Decoded payload is empty", raw);
|
|
78
|
+
}
|
|
79
|
+
if (decoded.byteLength > MAX_PAYLOAD_BYTES) {
|
|
80
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Decoded payload exceeds size limit", raw);
|
|
81
|
+
}
|
|
82
|
+
return new MessageEnvelope(decoded);
|
|
83
|
+
}
|
|
84
|
+
/* -------------------------------
|
|
85
|
+
* Extract Fingerprint (first one)
|
|
86
|
+
* ------------------------------- */
|
|
87
|
+
extractFingerprint(fingerprintLength = MessageEnvelope.DEFAULT_FINGERPRINT_LENGTH) {
|
|
88
|
+
const view = new Uint8Array(this.encryptedBlob);
|
|
89
|
+
if (view.length < 1 + fingerprintLength) {
|
|
90
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Encrypted blob too short to contain fingerprint");
|
|
91
|
+
}
|
|
92
|
+
const fingerprintBytes = view.slice(1, 1 + fingerprintLength);
|
|
93
|
+
const ab = fingerprintBytes.buffer.slice(fingerprintBytes.byteOffset, fingerprintBytes.byteOffset + fingerprintBytes.length);
|
|
94
|
+
return arrayBufferToBase64(ab);
|
|
95
|
+
}
|
|
96
|
+
/* -------------------------------
|
|
97
|
+
* Extract Encrypted Payload
|
|
98
|
+
* ------------------------------- */
|
|
99
|
+
extractEncryptedPayload() {
|
|
100
|
+
const view = new Uint8Array(this.encryptedBlob);
|
|
101
|
+
const versionLength = 1;
|
|
102
|
+
const fingerprintLength = MessageEnvelope.DEFAULT_FINGERPRINT_LENGTH;
|
|
103
|
+
if (view.length < versionLength + fingerprintLength + 1) {
|
|
104
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Encrypted blob too short to contain payload");
|
|
105
|
+
}
|
|
106
|
+
const payloadBytes = view.slice(versionLength + fingerprintLength);
|
|
107
|
+
const payloadText = new TextDecoder().decode(payloadBytes);
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(payloadText);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Failed to parse encrypted payload JSON");
|
|
114
|
+
}
|
|
115
|
+
// Validate multi-recipient
|
|
116
|
+
if ("keys" in parsed) {
|
|
117
|
+
if (!parsed.iv ||
|
|
118
|
+
!parsed.ciphertext ||
|
|
119
|
+
!parsed.ephemeralPublicKey ||
|
|
120
|
+
!Array.isArray(parsed.keys)) {
|
|
121
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Multi-recipient payload missing required fields");
|
|
122
|
+
}
|
|
123
|
+
for (const k of parsed.keys) {
|
|
124
|
+
if (!k.fingerprint || !k.ephemeralEncryptedKey || !k.nonce) {
|
|
125
|
+
throw new MessageEnvelopeError("VALIDATION_ERROR", "Invalid key entry in multi-recipient payload");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
131
|
+
/* -------------------------------
|
|
132
|
+
* Multi-Recipient Helpers
|
|
133
|
+
* ------------------------------- */
|
|
134
|
+
/**
|
|
135
|
+
* Returns the ephemeral encrypted key for a given fingerprint
|
|
136
|
+
*/
|
|
137
|
+
getRecipientKey(fingerprint) {
|
|
138
|
+
const payload = this.extractEncryptedPayload();
|
|
139
|
+
if ("keys" in payload) {
|
|
140
|
+
return payload.keys.find((k) => k.fingerprint === fingerprint);
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
getSingleRecipientPayload() {
|
|
145
|
+
const payload = this.extractEncryptedPayload();
|
|
146
|
+
return "keys" in payload ? undefined : payload;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Checks if this envelope contains a key for the given fingerprint
|
|
150
|
+
*/
|
|
151
|
+
hasRecipient(fingerprint) {
|
|
152
|
+
return !!this.getRecipientKey(fingerprint);
|
|
153
|
+
}
|
|
154
|
+
isGroup() {
|
|
155
|
+
const payload = this.extractEncryptedPayload();
|
|
156
|
+
return "keys" in payload;
|
|
157
|
+
}
|
|
158
|
+
isSolo() {
|
|
159
|
+
return !this.isGroup();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { MessageEnvelope } from "../messages/message-envelope";
|
|
2
|
+
import { MajikContactDirectory } from "../contacts/majik-contact-directory";
|
|
3
|
+
import { EnvelopeCache } from "../messages/envelope-cache";
|
|
4
|
+
interface ScannerEngineConfig {
|
|
5
|
+
contactDirectory: MajikContactDirectory;
|
|
6
|
+
envelopeCache?: EnvelopeCache;
|
|
7
|
+
onEnvelopeFound?: (envelope: MessageEnvelope) => void;
|
|
8
|
+
onUntrusted?: (raw: string) => void;
|
|
9
|
+
onError?: (err: Error, context?: {
|
|
10
|
+
raw?: string;
|
|
11
|
+
}) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare class ScannerEngine {
|
|
14
|
+
private observer?;
|
|
15
|
+
private contactDirectory;
|
|
16
|
+
private envelopeCache?;
|
|
17
|
+
private onEnvelopeFound?;
|
|
18
|
+
private onUntrusted?;
|
|
19
|
+
private onError?;
|
|
20
|
+
private processedNodes;
|
|
21
|
+
constructor(config: ScannerEngineConfig);
|
|
22
|
+
scanText(text: string): Promise<void>;
|
|
23
|
+
scanDOM(rootNode: Node): void;
|
|
24
|
+
startDOMObserver(rootNode: Node): void;
|
|
25
|
+
stopDOMObserver(): void;
|
|
26
|
+
}
|
|
27
|
+
export {};
|