@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.
Files changed (35) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +265 -0
  3. package/dist/core/contacts/majik-contact-directory.d.ts +34 -0
  4. package/dist/core/contacts/majik-contact-directory.js +165 -0
  5. package/dist/core/contacts/majik-contact.d.ts +53 -0
  6. package/dist/core/contacts/majik-contact.js +135 -0
  7. package/dist/core/crypto/constants.d.ts +7 -0
  8. package/dist/core/crypto/constants.js +6 -0
  9. package/dist/core/crypto/crypto-provider.d.ts +20 -0
  10. package/dist/core/crypto/crypto-provider.js +70 -0
  11. package/dist/core/crypto/encryption-engine.d.ts +59 -0
  12. package/dist/core/crypto/encryption-engine.js +257 -0
  13. package/dist/core/crypto/keystore.d.ts +126 -0
  14. package/dist/core/crypto/keystore.js +575 -0
  15. package/dist/core/messages/envelope-cache.d.ts +51 -0
  16. package/dist/core/messages/envelope-cache.js +375 -0
  17. package/dist/core/messages/message-envelope.d.ts +36 -0
  18. package/dist/core/messages/message-envelope.js +161 -0
  19. package/dist/core/scanner/scanner-engine.d.ts +27 -0
  20. package/dist/core/scanner/scanner-engine.js +120 -0
  21. package/dist/core/types.d.ts +23 -0
  22. package/dist/core/types.js +1 -0
  23. package/dist/core/utils/APITranscoder.d.ts +114 -0
  24. package/dist/core/utils/APITranscoder.js +305 -0
  25. package/dist/core/utils/idb-majik-system.d.ts +15 -0
  26. package/dist/core/utils/idb-majik-system.js +37 -0
  27. package/dist/core/utils/majik-file-utils.d.ts +16 -0
  28. package/dist/core/utils/majik-file-utils.js +153 -0
  29. package/dist/core/utils/utilities.d.ts +22 -0
  30. package/dist/core/utils/utilities.js +80 -0
  31. package/dist/index.d.ts +13 -0
  32. package/dist/index.js +12 -0
  33. package/dist/majik-message.d.ts +202 -0
  34. package/dist/majik-message.js +940 -0
  35. package/package.json +97 -0
@@ -0,0 +1,940 @@
1
+ // MajikMessage.ts
2
+ import { MajikContact, } from "./core/contacts/majik-contact";
3
+ import { KEY_ALGO } from "./core/crypto/constants";
4
+ import { ScannerEngine } from "./core/scanner/scanner-engine";
5
+ import { MessageEnvelope } from "./core/messages/message-envelope";
6
+ import { EnvelopeCache, } from "./core/messages/envelope-cache";
7
+ import { EncryptionEngine } from "./core/crypto/encryption-engine";
8
+ import { KeyStore } from "./core/crypto/keystore";
9
+ import { MajikContactDirectory, } from "./core/contacts/majik-contact-directory";
10
+ import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUtf8, utf8ToBase64, } from "./core/utils/utilities";
11
+ import { autoSaveMajikFileData, loadSavedMajikFileData, } from "./core/utils/majik-file-utils";
12
+ import { randomBytes } from "@stablelib/random";
13
+ import { idbLoadBlob, idbSaveBlob } from "./core/utils/idb-majik-system";
14
+ export class MajikMessage {
15
+ // Optional PIN protection (hashed). If set, UI should prompt for PIN to unlock.
16
+ pinHash = null;
17
+ id;
18
+ contactDirectory;
19
+ envelopeCache;
20
+ scanner;
21
+ listeners = new Map();
22
+ ownAccounts = new Map();
23
+ ownAccountsOrder = []; // keeps the order of IDs, first is active
24
+ autosaveTimer = null;
25
+ autosaveIntervalMs = 15000; // periodic backup interval
26
+ autosaveDebounceMs = 500; // debounce for rapid changes
27
+ constructor(config, id) {
28
+ this.id = id || arrayToBase64(randomBytes(32));
29
+ this.contactDirectory =
30
+ config.contactDirectory || new MajikContactDirectory();
31
+ this.envelopeCache = config.envelopeCache || new EnvelopeCache();
32
+ // Initialize scanner
33
+ this.scanner = new ScannerEngine({
34
+ contactDirectory: this.contactDirectory,
35
+ onEnvelopeFound: (env) => this.handleEnvelope(env),
36
+ onUntrusted: (raw) => this.emit("untrusted", raw),
37
+ onError: (err, ctx) => this.emit("error", err, ctx),
38
+ });
39
+ // Prepare listeners map
40
+ ["message", "envelope", "untrusted", "error"].forEach((e) => this.listeners.set(e, []));
41
+ // Attach autosave handlers so state is persisted automatically
42
+ this.attachAutosaveHandlers();
43
+ }
44
+ /* ================================
45
+ * Account Management
46
+ * ================================ */
47
+ /**
48
+ * Create a new account (generates identity via KeyStore) and add it as an own account.
49
+ * Returns the created identity id and a backup blob (base64) that the user should store.
50
+ */
51
+ async createAccount(passphrase, label) {
52
+ const identity = await KeyStore.createIdentity(passphrase);
53
+ // Import public key into a MajikContact
54
+ const contact = new MajikContact({
55
+ id: identity.id,
56
+ publicKey: identity.publicKey,
57
+ fingerprint: identity.fingerprint,
58
+ meta: { label: label || "" },
59
+ });
60
+ this.addOwnAccount(contact);
61
+ const backup = await KeyStore.exportIdentityBackup(identity.id);
62
+ return { id: identity.id, fingerprint: identity.fingerprint, backup };
63
+ }
64
+ /**
65
+ * Import an account from a backup blob (created with `exportIdentityBackup`) and unlock it.
66
+ */
67
+ async importAccountFromBackup(backupBase64, passphrase, label) {
68
+ await KeyStore.importIdentityBackup(backupBase64);
69
+ // Unlock the imported identity
70
+ const decoded = JSON.parse(base64ToUtf8(backupBase64));
71
+ const id = decoded.id;
72
+ const identity = await KeyStore.unlockIdentity(id, passphrase);
73
+ const contact = new MajikContact({
74
+ id: identity.id,
75
+ publicKey: identity.publicKey,
76
+ fingerprint: identity.fingerprint,
77
+ meta: { label: label || "" },
78
+ });
79
+ if (!!this.getOwnAccountById(identity.id)) {
80
+ throw new Error("Account with the same ID already exists");
81
+ }
82
+ this.addOwnAccount(contact);
83
+ return { id: identity.id, fingerprint: identity.fingerprint };
84
+ }
85
+ /**
86
+ * Generate a BIP39 mnemonic for backup (12 words by default).
87
+ */
88
+ generateMnemonic() {
89
+ return KeyStore.generateMnemonic();
90
+ }
91
+ /**
92
+ * Export a mnemonic-encrypted backup for an unlocked identity.
93
+ */
94
+ async exportAccountMnemonicBackup(id, mnemonic) {
95
+ return KeyStore.exportIdentityMnemonicBackup(id, mnemonic);
96
+ }
97
+ /**
98
+ * Import an account from a mnemonic-encrypted backup blob and store it using `passphrase`.
99
+ */
100
+ async importAccountFromMnemonicBackup(backupBase64, mnemonic, passphrase, label) {
101
+ const identity = await KeyStore.importIdentityFromMnemonicBackup(backupBase64, mnemonic, passphrase);
102
+ const contact = new MajikContact({
103
+ id: identity.id,
104
+ publicKey: identity.publicKey,
105
+ fingerprint: identity.fingerprint,
106
+ meta: { label: label || "" },
107
+ });
108
+ if (!!this.getOwnAccountById(identity.id)) {
109
+ throw new Error("Account with the same ID already exists");
110
+ }
111
+ this.addOwnAccount(contact);
112
+ return { id: identity.id, fingerprint: identity.fingerprint };
113
+ }
114
+ /**
115
+ * Create a new account deterministically from `mnemonic` and store it encrypted with `passphrase`.
116
+ * Returns the created identity id (which equals fingerprint) and fingerprint.
117
+ */
118
+ async createAccountFromMnemonic(mnemonic, passphrase, label) {
119
+ const identity = await KeyStore.createIdentityFromMnemonic(mnemonic, passphrase);
120
+ const contact = new MajikContact({
121
+ id: identity.id,
122
+ publicKey: identity.publicKey,
123
+ fingerprint: identity.fingerprint,
124
+ meta: { label: label || "" },
125
+ });
126
+ const backup = await KeyStore.exportIdentityMnemonicBackup(identity.id, mnemonic);
127
+ this.addOwnAccount(contact);
128
+ return {
129
+ id: identity.id,
130
+ fingerprint: identity.fingerprint,
131
+ backup: backup,
132
+ };
133
+ }
134
+ addOwnAccount(account) {
135
+ if (!this.ownAccounts.has(account.id)) {
136
+ this.ownAccounts.set(account.id, account);
137
+ this.ownAccountsOrder.push(account.id);
138
+ }
139
+ try {
140
+ if (!this.contactDirectory.hasContact(account.id)) {
141
+ this.contactDirectory.addContact(account);
142
+ }
143
+ }
144
+ catch (e) {
145
+ // ignore if contact can't be added
146
+ }
147
+ this.scheduleAutosave();
148
+ }
149
+ listOwnAccounts() {
150
+ const userAccounts = this.ownAccountsOrder
151
+ .map((id) => this.ownAccounts.get(id))
152
+ .filter((c) => !!c);
153
+ return userAccounts;
154
+ }
155
+ getOwnAccountById(id) {
156
+ return this.ownAccounts.get(id);
157
+ }
158
+ /**
159
+ * Set an active account (moves it to index 0)
160
+ */
161
+ setActiveAccount(id) {
162
+ if (!this.ownAccounts.has(id))
163
+ return false;
164
+ // Remove ID from current position
165
+ const index = this.ownAccountsOrder.indexOf(id);
166
+ if (index > -1)
167
+ this.ownAccountsOrder.splice(index, 1);
168
+ // Add to the front
169
+ this.ownAccountsOrder.unshift(id);
170
+ this.scheduleAutosave();
171
+ return true;
172
+ }
173
+ getActiveAccount() {
174
+ if (this.ownAccountsOrder.length === 0)
175
+ return null;
176
+ return this.ownAccounts.get(this.ownAccountsOrder[0]) || null;
177
+ }
178
+ isAccountActive(id) {
179
+ if (!this.ownAccounts.has(id))
180
+ return false;
181
+ if (this.ownAccountsOrder.length === 0)
182
+ return false;
183
+ return this.ownAccountsOrder[0] === id;
184
+ }
185
+ /**
186
+ * Remove an own account from the in-memory registry.
187
+ */
188
+ removeOwnAccount(id) {
189
+ if (!this.ownAccounts.has(id))
190
+ return false;
191
+ this.ownAccounts.delete(id);
192
+ const idx = this.ownAccountsOrder.indexOf(id);
193
+ if (idx > -1)
194
+ this.ownAccountsOrder.splice(idx, 1);
195
+ this.removeContact(id);
196
+ // remove cached envelopes addressed to this identity
197
+ this.envelopeCache.deleteByFingerprint(id).catch((error) => {
198
+ console.warn("Account not found in cache: ", error);
199
+ });
200
+ this.scheduleAutosave();
201
+ return true;
202
+ }
203
+ /**
204
+ * Retrieve a contact from the directory by ID.
205
+ * Validates that the input is a non-empty string.
206
+ * Returns the MajikContact instance or null if not found.
207
+ */
208
+ getContactByID(id) {
209
+ if (typeof id !== "string" || !id.trim()) {
210
+ throw new Error("Invalid contact ID: must be a non-empty string");
211
+ }
212
+ if (!this.contactDirectory.hasContact(id)) {
213
+ return null; // Not found
214
+ }
215
+ return this.contactDirectory.getContact(id) ?? null;
216
+ }
217
+ /**
218
+ * Returns a JSON string representation of a contact
219
+ * suitable for sharing.
220
+ */
221
+ async exportContactAsJSON(contactId) {
222
+ const contact = this.contactDirectory.getContact(contactId);
223
+ if (!contact)
224
+ return null;
225
+ // Support raw-key wrappers produced by the Stablelib provider
226
+ let publicKeyBase64;
227
+ const anyPub = contact.publicKey;
228
+ if (anyPub && anyPub.raw instanceof Uint8Array) {
229
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
230
+ }
231
+ else {
232
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
233
+ publicKeyBase64 = arrayBufferToBase64(raw);
234
+ }
235
+ const payload = {
236
+ id: contact.id,
237
+ label: contact.meta?.label || "",
238
+ publicKey: publicKeyBase64,
239
+ fingerprint: contact.fingerprint,
240
+ };
241
+ return JSON.stringify(payload, null, 2); // pretty-print for easier copy-paste
242
+ }
243
+ /**
244
+ * Returns a compact base64 string for sharing a contact.
245
+ * Encodes JSON payload into base64.
246
+ */
247
+ async exportContactAsString(contactId) {
248
+ const json = await this.exportContactAsJSON(contactId);
249
+ if (!json)
250
+ return null;
251
+ return utf8ToBase64(json);
252
+ }
253
+ /* ================================
254
+ * Contact Management
255
+ * ================================ */
256
+ async importContactFromJSON(jsonStr) {
257
+ try {
258
+ const data = JSON.parse(jsonStr);
259
+ if (!data.id || !data.publicKey || !data.fingerprint)
260
+ return {
261
+ success: false,
262
+ message: "Invalid contact JSON",
263
+ };
264
+ // If publicKey is a base64 string, import it
265
+ let publicKeyPromise;
266
+ if (typeof data.publicKey === "string") {
267
+ try {
268
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
269
+ try {
270
+ publicKeyPromise = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
271
+ }
272
+ catch (e) {
273
+ // Fallback: create a raw-key wrapper when the browser does not support the namedCurve
274
+ publicKeyPromise = { raw: new Uint8Array(rawBuffer) };
275
+ }
276
+ }
277
+ catch (e) {
278
+ console.error("Failed to parse publicKey base64", e);
279
+ return {
280
+ success: false,
281
+ message: "Failed to parse publicKey base64",
282
+ };
283
+ }
284
+ }
285
+ else {
286
+ // assume already a CryptoKey
287
+ publicKeyPromise = await Promise.resolve(data.publicKey);
288
+ }
289
+ const contact = new MajikContact({
290
+ id: data.id,
291
+ publicKey: publicKeyPromise,
292
+ fingerprint: data.fingerprint,
293
+ meta: { label: data.label },
294
+ });
295
+ this.addContact(contact);
296
+ return {
297
+ success: true,
298
+ message: "Contact imported successfully",
299
+ };
300
+ }
301
+ catch (err) {
302
+ console.error("Failed to import contact from JSON:", err);
303
+ return {
304
+ success: false,
305
+ message: err instanceof Error ? err.message : "Unknown error",
306
+ };
307
+ }
308
+ }
309
+ /**
310
+ * List cached envelopes stored in the local cache (most recent first).
311
+ * Returns objects: { id, envelope, timestamp, source }
312
+ */
313
+ async listCachedEnvelopes(offset = 0, limit = 50) {
314
+ return await this.envelopeCache.listRecent(offset, limit);
315
+ }
316
+ /**
317
+ * Clear cached envelopes stored in the local cache.
318
+ */
319
+ async clearCachedEnvelopes() {
320
+ const response = await this.envelopeCache.clear();
321
+ if (!response?.success) {
322
+ throw new Error(response.message);
323
+ }
324
+ this.scheduleAutosave();
325
+ return response.success;
326
+ }
327
+ async hasOwnIdentity(fingerprint) {
328
+ return await KeyStore.hasIdentity(fingerprint);
329
+ }
330
+ /**
331
+ * Attempt to decrypt a given envelope and return the plaintext string.
332
+ * Will prompt to unlock identity if necessary.
333
+ */
334
+ async decryptEnvelope(envelope, bypassIdentity = false) {
335
+ const fingerprint = envelope.extractFingerprint();
336
+ const authorizedAccount = this.listContacts(true).find((a) => a.fingerprint === fingerprint);
337
+ if (!authorizedAccount) {
338
+ throw new Error("No matching own account to decrypt this envelope");
339
+ }
340
+ let privateKey;
341
+ if (bypassIdentity) {
342
+ const activeAccount = this.getActiveAccount();
343
+ if (!activeAccount) {
344
+ throw new Error("No active account available to bypass identity check");
345
+ }
346
+ privateKey = await KeyStore.getPrivateKey(activeAccount.id);
347
+ }
348
+ else {
349
+ privateKey = await this.ensureIdentityUnlocked(authorizedAccount.id);
350
+ }
351
+ if (!privateKey) {
352
+ throw new Error("No private key found for this fingerprint.");
353
+ }
354
+ let decrypted;
355
+ if (envelope.isGroup()) {
356
+ const recipientKey = envelope.getRecipientKey(fingerprint);
357
+ if (!recipientKey) {
358
+ throw new Error("No recipient key found for this fingerprint");
359
+ }
360
+ decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, fingerprint);
361
+ }
362
+ else {
363
+ decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
364
+ }
365
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
366
+ ? window.location.hostname
367
+ : "extension");
368
+ return decrypted;
369
+ }
370
+ async importContactFromString(base64Str) {
371
+ const jsonStr = base64ToUtf8(base64Str);
372
+ const isImportSuccess = await this.importContactFromJSON(jsonStr);
373
+ if (!isImportSuccess.success)
374
+ throw new Error(isImportSuccess.message);
375
+ }
376
+ addContact(contact) {
377
+ this.contactDirectory.addContact(contact);
378
+ this.scheduleAutosave();
379
+ }
380
+ removeContact(id) {
381
+ const removalStatus = this.contactDirectory.removeContact(id);
382
+ if (!removalStatus.success) {
383
+ throw new Error(removalStatus.message);
384
+ }
385
+ this.scheduleAutosave();
386
+ }
387
+ updateContactMeta(id, meta) {
388
+ this.contactDirectory.updateContactMeta(id, meta);
389
+ this.scheduleAutosave();
390
+ }
391
+ blockContact(id) {
392
+ this.contactDirectory.blockContact(id);
393
+ this.scheduleAutosave();
394
+ }
395
+ unblockContact(id) {
396
+ this.contactDirectory.unblockContact(id);
397
+ this.scheduleAutosave();
398
+ }
399
+ listContacts(all = true) {
400
+ const contacts = this.contactDirectory.listContacts(true);
401
+ if (all) {
402
+ return contacts;
403
+ }
404
+ const userAccounts = this.listOwnAccounts();
405
+ const userAccountIds = new Set(userAccounts.map((a) => a.id));
406
+ return contacts.filter((contact) => !userAccountIds.has(contact.id));
407
+ }
408
+ /**
409
+ * Update the passphrase for an identity.
410
+ * - `id` defaults to the current active account if not provided.
411
+ * - Throws if no active account exists or passphrase is invalid.
412
+ */
413
+ async updatePassphrase(currentPassphrase, newPassphrase, id) {
414
+ // Determine target account
415
+ const targetAccount = id
416
+ ? this.getOwnAccountById(id)
417
+ : this.getActiveAccount();
418
+ if (!targetAccount) {
419
+ throw new Error("No target account specified and no active account available");
420
+ }
421
+ // Delegate to KeyStore
422
+ await KeyStore.updatePassphrase(targetAccount.id, currentPassphrase, newPassphrase);
423
+ // Optionally emit an event or autosave
424
+ this.scheduleAutosave();
425
+ }
426
+ /* ================================
427
+ * Encryption / Decryption
428
+ * ================================ */
429
+ /**
430
+ * Encrypts a plaintext message for a single recipient (solo message)
431
+ * Returns a MessageEnvelope instance and caches it automatically.
432
+ */
433
+ async encryptSoloMessage(toId, plaintext, cache = true) {
434
+ const contact = this.contactDirectory.getContact(toId);
435
+ if (!contact)
436
+ throw new Error(`No contact with id "${toId}"`);
437
+ const payload = await EncryptionEngine.encryptSoloMessage(plaintext, contact.publicKey);
438
+ const payloadJSON = JSON.stringify(payload);
439
+ const encoder = new TextEncoder();
440
+ const payloadBytes = encoder.encode(payloadJSON);
441
+ // Envelope: [version byte][fingerprint][payload]
442
+ const versionByte = new Uint8Array([1]);
443
+ const fingerprintBytes = new Uint8Array(base64ToArrayBuffer(contact.fingerprint));
444
+ const blob = new Uint8Array(versionByte.length + fingerprintBytes.length + payloadBytes.length);
445
+ blob.set(versionByte, 0);
446
+ blob.set(fingerprintBytes, versionByte.length);
447
+ blob.set(payloadBytes, versionByte.length + fingerprintBytes.length);
448
+ const envelope = new MessageEnvelope(blob.buffer);
449
+ if (!!cache) {
450
+ // Cache envelope
451
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
452
+ ? window.location.hostname
453
+ : "extension");
454
+ }
455
+ this.scheduleAutosave();
456
+ this.emit("envelope", envelope);
457
+ return envelope;
458
+ }
459
+ /**
460
+ * Encrypts a plaintext message for a group of recipients.
461
+ * Returns a unified group MessageEnvelope instance.
462
+ */
463
+ async encryptGroupMessage(recipientIds, plaintext, cache = true) {
464
+ if (!recipientIds.length) {
465
+ throw new Error("No recipients provided");
466
+ }
467
+ // Resolve recipients and their keys
468
+ const recipients = recipientIds.map((id) => {
469
+ const contact = this.contactDirectory.getContact(id);
470
+ if (!contact)
471
+ throw new Error(`No contact with id "${id}"`);
472
+ return {
473
+ id: contact.id,
474
+ publicKey: contact.publicKey,
475
+ fingerprint: contact.fingerprint,
476
+ };
477
+ });
478
+ // 🔐 Encrypt once for all recipients
479
+ const payload = await EncryptionEngine.encryptGroupMessage(plaintext, recipients);
480
+ // Serialize payload
481
+ const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
482
+ // Envelope structure: [version byte][sender fingerprint][payload bytes]
483
+ const versionByte = new Uint8Array([2]);
484
+ // Use sender fingerprint for group envelope
485
+ const activeAccount = this.getActiveAccount();
486
+ if (!activeAccount)
487
+ throw new Error("No active account to send from");
488
+ const fingerprintBytes = new Uint8Array(base64ToArrayBuffer(activeAccount.fingerprint));
489
+ // Combine all parts into a single Uint8Array
490
+ const blob = new Uint8Array(versionByte.length + fingerprintBytes.length + payloadBytes.length);
491
+ blob.set(versionByte, 0);
492
+ blob.set(fingerprintBytes, versionByte.length);
493
+ blob.set(payloadBytes, versionByte.length + fingerprintBytes.length);
494
+ // Wrap as MessageEnvelope
495
+ const envelope = new MessageEnvelope(blob.buffer);
496
+ if (!!cache) {
497
+ // Cache envelope
498
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
499
+ ? window.location.hostname
500
+ : "extension");
501
+ }
502
+ this.scheduleAutosave();
503
+ this.emit("envelope", envelope);
504
+ return envelope;
505
+ }
506
+ async sendMessage(recipients, plaintext) {
507
+ if (recipients.length === 0 || !recipients) {
508
+ throw new Error("No recipients provided. At least one recipient is required.");
509
+ }
510
+ if (recipients.length === 1) {
511
+ return await this.encryptSoloMessage(recipients[0], plaintext);
512
+ }
513
+ else {
514
+ return await this.encryptGroupMessage(recipients, plaintext);
515
+ }
516
+ }
517
+ /* ================================
518
+ * High-Level DOM Wrapper
519
+ * ================================ */
520
+ /**
521
+ * Encrypts currently selected text in the browser DOM for given recipients.
522
+ * If `recipients` is empty, defaults to the first own account.
523
+ * Returns the fully serialized base64 envelope string for the scanner.
524
+ */
525
+ async encryptSelectedTextForScanner(recipients = []) {
526
+ // Delegate to textarea-agnostic implementation
527
+ const plaintext = window.getSelection()?.toString().trim() ?? "";
528
+ return await this.encryptTextForScanner(plaintext, recipients);
529
+ }
530
+ /**
531
+ * Encrypt a provided plaintext string and return a serialized MajikMessage envelope string.
532
+ * Supports single or multiple recipients. Safe to call from background contexts.
533
+ */
534
+ async encryptTextForScanner(plaintext, recipients = [], cache = true) {
535
+ if (!plaintext?.trim()) {
536
+ console.warn("No text provided to encrypt.");
537
+ return null;
538
+ }
539
+ try {
540
+ // Determine recipients: default to first own account if none provided
541
+ if (recipients.length === 0) {
542
+ const firstOwn = this.listOwnAccounts()[0];
543
+ if (!firstOwn)
544
+ throw new Error("No own account available for encryption.");
545
+ recipients = [firstOwn.id];
546
+ }
547
+ let envelope;
548
+ if (recipients.length === 1) {
549
+ // Single recipient → solo message
550
+ envelope = await this.encryptSoloMessage(recipients[0], plaintext, cache);
551
+ }
552
+ else {
553
+ // Multiple recipients → unified group message
554
+ envelope = await this.encryptGroupMessage(recipients, plaintext, cache);
555
+ }
556
+ // Convert envelope to base64 for scanner
557
+ const envelopeBase64 = arrayBufferToBase64(envelope.raw);
558
+ return `${MessageEnvelope.PREFIX}:${envelopeBase64}`;
559
+ }
560
+ catch (err) {
561
+ this.emit("error", err, { context: "encryptTextForScanner" });
562
+ return null;
563
+ }
564
+ }
565
+ /**
566
+ * Encrypt text for a given target (label or ID).
567
+ * - If target is self or not found, just encrypt normally.
568
+ * - If target is another contact, always make a group message including self + target.
569
+ */
570
+ async encryptForTarget(target, // can be label or id
571
+ plaintext) {
572
+ const activeAccount = this.getActiveAccount();
573
+ if (!activeAccount)
574
+ throw new Error("No active account available");
575
+ // Try to find contact by ID first
576
+ let contact = this.listContacts(false).find((c) => c.id === target);
577
+ // If not found by ID, try by label
578
+ if (!contact) {
579
+ contact = this.listContacts(false).find((c) => c.meta?.label === target);
580
+ }
581
+ // If still not found or it's self → solo encryption
582
+ if (!contact || contact.id === activeAccount.id) {
583
+ return this.encryptTextForScanner(plaintext, [activeAccount.id]);
584
+ }
585
+ // Otherwise → group with self + target contact
586
+ return this.encryptTextForScanner(plaintext, [
587
+ activeAccount.id,
588
+ contact.id,
589
+ ]);
590
+ }
591
+ /* ================================
592
+ * DOM Scanning
593
+ * ================================ */
594
+ scanDOM(rootNode) {
595
+ this.scanner.scanDOM(rootNode);
596
+ }
597
+ startDOMObserver(rootNode) {
598
+ this.scanner.startDOMObserver(rootNode);
599
+ }
600
+ stopDOMObserver() {
601
+ this.scanner.stopDOMObserver();
602
+ }
603
+ /* ================================
604
+ * Event Handling
605
+ * ================================ */
606
+ on(event, callback) {
607
+ this.listeners.get(event)?.push(callback);
608
+ }
609
+ emit(event, ...args) {
610
+ this.listeners.get(event)?.forEach((cb) => cb(...args));
611
+ }
612
+ /* ================================
613
+ * Envelope Handling
614
+ * ================================ */
615
+ async handleEnvelope(envelope) {
616
+ // Skip if already cached
617
+ const cached = await this.envelopeCache.get(envelope);
618
+ if (cached)
619
+ return;
620
+ const fingerprint = envelope.extractFingerprint();
621
+ // contact is the directory entry (useful for metadata); ownAccount
622
+ // must match fingerprint for this device to be able to decrypt.
623
+ const contact = this.contactDirectory.getContactByFingerprint(fingerprint);
624
+ const ownAccount = this.listOwnAccounts().find((a) => a.fingerprint === fingerprint);
625
+ // If this envelope isn't addressed to one of our own accounts, mark as untrusted
626
+ if (!ownAccount) {
627
+ this.emit("untrusted", envelope);
628
+ return;
629
+ }
630
+ try {
631
+ // Ensure identity unlocked; try interactive prompt if needed
632
+ const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
633
+ let decrypted;
634
+ if (envelope.isGroup()) {
635
+ const recipientKey = envelope.getRecipientKey(fingerprint);
636
+ if (!recipientKey) {
637
+ throw new Error("No recipient key found for this fingerprint");
638
+ }
639
+ decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, fingerprint);
640
+ }
641
+ else {
642
+ decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
643
+ }
644
+ // Cache envelope (record source as current hostname when available)
645
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
646
+ ? window.location.hostname
647
+ : "extension");
648
+ this.scheduleAutosave();
649
+ this.emit("message", decrypted, envelope, contact);
650
+ }
651
+ catch (err) {
652
+ this.emit("error", err, { envelope });
653
+ }
654
+ }
655
+ /**
656
+ * Ensure an identity is unlocked. If locked, prompt the user for a passphrase.
657
+ * `promptFn` can be provided to show a custom UI: either synchronous returning string
658
+ * or async Promise<string>. If omitted, falls back to `window.prompt`.
659
+ */
660
+ async ensureIdentityUnlocked(id, promptFn) {
661
+ try {
662
+ return await KeyStore.getPrivateKey(id);
663
+ }
664
+ catch (err) {
665
+ // If KeyStore indicates unlocking is required, prompt the user
666
+ const needsUnlock = err instanceof Error &&
667
+ /must be unlocked|unlockIdentity/.test(err.message);
668
+ if (!needsUnlock)
669
+ throw err;
670
+ // Ask for passphrase
671
+ let passphrase = null;
672
+ if (promptFn) {
673
+ const res = promptFn(id);
674
+ passphrase = typeof res === "string" ? res : await res;
675
+ }
676
+ else if (KeyStore.onUnlockRequested) {
677
+ const res = KeyStore.onUnlockRequested(id);
678
+ passphrase = typeof res === "string" ? res : await res;
679
+ }
680
+ else if (typeof window !== "undefined" && window.prompt) {
681
+ passphrase = window.prompt("Enter passphrase to unlock identity:", "");
682
+ }
683
+ if (!passphrase)
684
+ throw new Error("Unlock cancelled");
685
+ // Attempt to unlock
686
+ await KeyStore.unlockIdentity(id, passphrase);
687
+ return await KeyStore.getPrivateKey(id);
688
+ }
689
+ }
690
+ async isPassphraseValid(passphrase, id) {
691
+ const target = id ? this.getOwnAccountById(id) : this.getActiveAccount();
692
+ if (!target)
693
+ return false;
694
+ return KeyStore.isPassphraseValid(target.id, passphrase);
695
+ }
696
+ async toJSON() {
697
+ const finalJSON = {
698
+ id: this.id,
699
+ contacts: await this.contactDirectory.toJSON(),
700
+ envelopeCache: this.envelopeCache.toJSON(),
701
+ };
702
+ // Serialize own accounts (preserve order)
703
+ try {
704
+ const ownAccountsArr = [];
705
+ for (const id of this.ownAccountsOrder) {
706
+ const acct = this.ownAccounts.get(id);
707
+ if (!acct)
708
+ continue;
709
+ ownAccountsArr.push(await acct.toJSON());
710
+ }
711
+ finalJSON.ownAccounts = {
712
+ accounts: ownAccountsArr,
713
+ order: [...this.ownAccountsOrder],
714
+ };
715
+ }
716
+ catch (e) {
717
+ // ignore serialization errors for own accounts
718
+ console.warn("Failed to serialize ownAccounts:", e);
719
+ }
720
+ // include optional PIN hash
721
+ finalJSON.pinHash = this.pinHash || null;
722
+ return finalJSON;
723
+ }
724
+ static async fromJSON(json) {
725
+ const newDirectory = new MajikContactDirectory();
726
+ const parsedContacts = await newDirectory.fromJSON(json.contacts);
727
+ const parsedEnvelopeCache = EnvelopeCache.fromJSON(json.envelopeCache);
728
+ const parsedInstance = new MajikMessage({
729
+ contactDirectory: parsedContacts,
730
+ envelopeCache: parsedEnvelopeCache,
731
+ keyStore: KeyStore,
732
+ }, json.id);
733
+ // Restore ownAccounts if present
734
+ try {
735
+ if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
736
+ for (const acct of json.ownAccounts.accounts) {
737
+ try {
738
+ const raw = base64ToArrayBuffer(acct.publicKeyBase64);
739
+ const publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
740
+ const contact = MajikContact.create(acct.id, publicKey, acct.fingerprint, acct.meta);
741
+ parsedInstance.ownAccounts.set(contact.id, contact);
742
+ }
743
+ catch (e) {
744
+ // SubtleCrypto may not support importing X25519 keys in some environments.
745
+ // This is non-fatal: we fall back to raw-key wrappers elsewhere.
746
+ console.info("Fallback restoring own account (using raw-key wrapper)", acct.id, e);
747
+ }
748
+ }
749
+ if (Array.isArray(json.ownAccounts.order)) {
750
+ parsedInstance.ownAccountsOrder = [...json.ownAccounts.order];
751
+ }
752
+ // Fallback: if accounts array was empty but order exists, try to populate
753
+ // ownAccounts from the restored contactDirectory entries
754
+ try {
755
+ if (Array.isArray(json.ownAccounts.order) &&
756
+ parsedInstance.ownAccounts.size === 0) {
757
+ for (const id of json.ownAccounts.order) {
758
+ try {
759
+ const c = parsedInstance.contactDirectory.getContact(id);
760
+ if (c)
761
+ parsedInstance.ownAccounts.set(id, c);
762
+ }
763
+ catch (e) {
764
+ // ignore missing contacts
765
+ }
766
+ }
767
+ }
768
+ }
769
+ catch (e) {
770
+ // ignore
771
+ }
772
+ // Also add own accounts into contact directory for discoverability
773
+ try {
774
+ parsedInstance.ownAccountsOrder.forEach((id) => {
775
+ const c = parsedInstance.ownAccounts.get(id);
776
+ if (c && !parsedInstance.contactDirectory.hasContact(c.id)) {
777
+ parsedInstance.contactDirectory.addContact(c);
778
+ }
779
+ });
780
+ }
781
+ catch (e) {
782
+ // ignore
783
+ }
784
+ }
785
+ }
786
+ catch (e) {
787
+ console.warn("Error restoring ownAccounts:", e);
788
+ }
789
+ // restore pin hash if present
790
+ try {
791
+ const anyJson = json;
792
+ if (anyJson.pinHash)
793
+ parsedInstance.pinHash = anyJson.pinHash;
794
+ }
795
+ catch (e) {
796
+ // ignore
797
+ }
798
+ return parsedInstance;
799
+ }
800
+ /**
801
+ * Set a PIN (stores hash). Passphrase is any string; we store SHA-256(base64) of it.
802
+ */
803
+ async setPIN(pin) {
804
+ if (!pin)
805
+ throw new Error("PIN must be a non-empty string");
806
+ const hash = await MajikMessage.hashPIN(pin);
807
+ this.pinHash = hash;
808
+ this.scheduleAutosave();
809
+ }
810
+ async clearPIN() {
811
+ this.pinHash = null;
812
+ this.scheduleAutosave();
813
+ }
814
+ async isValidPIN(pin) {
815
+ if (!this.pinHash)
816
+ return true; // no PIN set => always valid
817
+ const hash = await MajikMessage.hashPIN(pin);
818
+ return hash === this.pinHash;
819
+ }
820
+ getPinHash() {
821
+ return this.pinHash || null;
822
+ }
823
+ static async hashPIN(pin) {
824
+ const data = new TextEncoder().encode(pin);
825
+ const digest = await crypto.subtle.digest("SHA-256", data);
826
+ // base64 encode
827
+ const b64 = arrayBufferToBase64(digest);
828
+ return b64;
829
+ }
830
+ /* ================================
831
+ * Persistence
832
+ * ================================ */
833
+ autosaveIntervalId = null;
834
+ attachAutosaveHandlers() {
835
+ if (typeof window !== "undefined") {
836
+ // Save before unload (best-effort)
837
+ try {
838
+ window.addEventListener("beforeunload", () => {
839
+ void this.saveState();
840
+ });
841
+ }
842
+ catch (e) {
843
+ // ignore
844
+ }
845
+ // Start periodic backups
846
+ this.startAutosave();
847
+ }
848
+ }
849
+ startAutosave() {
850
+ if (this.autosaveIntervalId)
851
+ return;
852
+ if (typeof window === "undefined")
853
+ return;
854
+ this.autosaveIntervalId = window.setInterval(() => {
855
+ void this.saveState();
856
+ }, this.autosaveIntervalMs);
857
+ }
858
+ stopAutosave() {
859
+ if (!this.autosaveIntervalId)
860
+ return;
861
+ if (typeof window !== "undefined") {
862
+ window.clearInterval(this.autosaveIntervalId);
863
+ }
864
+ this.autosaveIntervalId = null;
865
+ }
866
+ scheduleAutosave() {
867
+ try {
868
+ if (this.autosaveTimer) {
869
+ if (typeof window !== "undefined")
870
+ window.clearTimeout(this.autosaveTimer);
871
+ this.autosaveTimer = null;
872
+ }
873
+ if (typeof window !== "undefined") {
874
+ this.autosaveTimer = window.setTimeout(() => {
875
+ void this.saveState();
876
+ this.autosaveTimer = null;
877
+ }, this.autosaveDebounceMs);
878
+ }
879
+ }
880
+ catch (e) {
881
+ // ignore scheduling errors
882
+ }
883
+ }
884
+ /** Save current state into IndexedDB (autosave). */
885
+ async saveState() {
886
+ try {
887
+ const jsonDocument = await this.toJSON();
888
+ const autosaveBlob = autoSaveMajikFileData(jsonDocument);
889
+ await idbSaveBlob("majik-message-state", autosaveBlob);
890
+ }
891
+ catch (err) {
892
+ console.error("Failed to save MajikMessage state:", err);
893
+ }
894
+ }
895
+ /** Load state from IndexedDB and apply to this instance. */
896
+ async loadState() {
897
+ try {
898
+ const autosaveData = await idbLoadBlob("majik-message-state");
899
+ if (!autosaveData?.data)
900
+ return;
901
+ const blobFile = autosaveData.data;
902
+ const loadedData = await loadSavedMajikFileData(blobFile);
903
+ const parsedJSON = loadedData.j;
904
+ // Use fromJSON to ensure ownAccounts and other fields are restored consistently
905
+ const restored = await MajikMessage.fromJSON(parsedJSON);
906
+ this.id = restored.id;
907
+ this.contactDirectory = restored.contactDirectory;
908
+ this.envelopeCache = restored.envelopeCache;
909
+ this.ownAccounts = restored.ownAccounts;
910
+ this.ownAccountsOrder = [...restored.ownAccountsOrder];
911
+ }
912
+ catch (err) {
913
+ console.error("Failed to load MajikMessage state:", err);
914
+ }
915
+ }
916
+ /**
917
+ * Try to load an existing state from IDB; if none exists, create a fresh instance and save it.
918
+ */
919
+ static async loadOrCreate(config) {
920
+ try {
921
+ const saved = await idbLoadBlob("majik-message-state");
922
+ if (saved?.data) {
923
+ const loaded = await loadSavedMajikFileData(saved.data);
924
+ const parsedJSON = loaded.j;
925
+ const instance = await MajikMessage.fromJSON(parsedJSON);
926
+ console.log("Account Loaded Successfully");
927
+ instance.attachAutosaveHandlers();
928
+ return instance;
929
+ }
930
+ }
931
+ catch (err) {
932
+ console.warn("Error trying to load saved MajikMessage state:", err);
933
+ }
934
+ // No saved state; create new and persist initial state
935
+ const created = new MajikMessage(config);
936
+ await created.saveState();
937
+ created.attachAutosaveHandlers();
938
+ return created;
939
+ }
940
+ }