@majikah/majik-message 0.1.1
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 +559 -0
- package/dist/core/compressor/majik-compressor.d.ts +18 -0
- package/dist/core/compressor/majik-compressor.js +100 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +42 -0
- package/dist/core/contacts/majik-contact-directory.js +203 -0
- package/dist/core/contacts/majik-contact.d.ts +72 -0
- package/dist/core/contacts/majik-contact.js +192 -0
- package/dist/core/crypto/constants.d.ts +8 -0
- package/dist/core/crypto/constants.js +7 -0
- package/dist/core/crypto/crypto-provider.d.ts +21 -0
- package/dist/core/crypto/crypto-provider.js +73 -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 +128 -0
- package/dist/core/crypto/keystore.js +596 -0
- package/dist/core/database/chat/majik-message-chat.d.ts +117 -0
- package/dist/core/database/chat/majik-message-chat.js +513 -0
- package/dist/core/database/chat/types.d.ts +14 -0
- package/dist/core/database/chat/types.js +1 -0
- package/dist/core/database/system/identity.d.ts +61 -0
- package/dist/core/database/system/identity.js +171 -0
- package/dist/core/database/system/utils.d.ts +1 -0
- package/dist/core/database/system/utils.js +8 -0
- package/dist/core/database/thread/enums.d.ts +7 -0
- package/dist/core/database/thread/enums.js +6 -0
- package/dist/core/database/thread/mail/majik-message-mail.d.ts +177 -0
- package/dist/core/database/thread/mail/majik-message-mail.js +704 -0
- package/dist/core/database/thread/majik-message-thread.d.ts +166 -0
- package/dist/core/database/thread/majik-message-thread.js +637 -0
- package/dist/core/messages/envelope-cache.d.ts +52 -0
- package/dist/core/messages/envelope-cache.js +377 -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 +28 -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 +44 -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 +29 -0
- package/dist/core/utils/utilities.js +94 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -0
- package/dist/majik-message.d.ts +247 -0
- package/dist/majik-message.js +1221 -0
- package/package.json +104 -0
|
@@ -0,0 +1,1221 @@
|
|
|
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, seedStringToArray, utf8ToBase64, } from "./core/utils/utilities";
|
|
11
|
+
import { autoSaveMajikFileData, loadSavedMajikFileData, } from "./core/utils/majik-file-utils";
|
|
12
|
+
import { randomBytes } from "@stablelib/random";
|
|
13
|
+
import { clearAllBlobs, idbLoadBlob, idbSaveBlob, } from "./core/utils/idb-majik-system";
|
|
14
|
+
import { MajikMessageChat } from "./core/database/chat/majik-message-chat";
|
|
15
|
+
import { MajikCompressor } from "./core/compressor/majik-compressor";
|
|
16
|
+
import { MajikKey } from "@thezelijah/majik-key";
|
|
17
|
+
export class MajikMessage {
|
|
18
|
+
userProfile = "default";
|
|
19
|
+
// Optional PIN protection (hashed). If set, UI should prompt for PIN to unlock.
|
|
20
|
+
pinHash = null;
|
|
21
|
+
id;
|
|
22
|
+
contactDirectory;
|
|
23
|
+
envelopeCache;
|
|
24
|
+
scanner;
|
|
25
|
+
listeners = new Map();
|
|
26
|
+
ownAccounts = new Map();
|
|
27
|
+
ownAccountsOrder = []; // keeps the order of IDs, first is active
|
|
28
|
+
autosaveTimer = null;
|
|
29
|
+
autosaveIntervalMs = 15000; // periodic backup interval
|
|
30
|
+
autosaveDebounceMs = 500; // debounce for rapid changes
|
|
31
|
+
unlocked = false;
|
|
32
|
+
constructor(config, id, userProfile = "default") {
|
|
33
|
+
this.userProfile = userProfile || "default";
|
|
34
|
+
this.id = id || arrayToBase64(randomBytes(32));
|
|
35
|
+
this.contactDirectory =
|
|
36
|
+
config.contactDirectory || new MajikContactDirectory();
|
|
37
|
+
this.envelopeCache =
|
|
38
|
+
config.envelopeCache || new EnvelopeCache(undefined, userProfile);
|
|
39
|
+
// Initialize scanner
|
|
40
|
+
this.scanner = new ScannerEngine({
|
|
41
|
+
contactDirectory: this.contactDirectory,
|
|
42
|
+
onEnvelopeFound: (env) => this.handleEnvelope(env),
|
|
43
|
+
onUntrusted: (raw) => this.emit("untrusted", raw),
|
|
44
|
+
onError: (err, ctx) => this.emit("error", err, ctx),
|
|
45
|
+
});
|
|
46
|
+
// Prepare listeners map
|
|
47
|
+
[
|
|
48
|
+
"message",
|
|
49
|
+
"envelope",
|
|
50
|
+
"untrusted",
|
|
51
|
+
"error",
|
|
52
|
+
"new-account",
|
|
53
|
+
"new-contact",
|
|
54
|
+
"removed-account",
|
|
55
|
+
"removed-contact",
|
|
56
|
+
"active-account-change",
|
|
57
|
+
].forEach((e) => this.listeners.set(e, []));
|
|
58
|
+
// Attach autosave handlers so state is persisted automatically
|
|
59
|
+
this.attachAutosaveHandlers();
|
|
60
|
+
}
|
|
61
|
+
/* ================================
|
|
62
|
+
* Account Management
|
|
63
|
+
* ================================ */
|
|
64
|
+
/**
|
|
65
|
+
* Create a new account (generates identity via KeyStore) and add it as an own account.
|
|
66
|
+
* Returns the created identity id and a backup blob (base64) that the user should store.
|
|
67
|
+
*/
|
|
68
|
+
async createAccount(passphrase, label) {
|
|
69
|
+
const identity = await KeyStore.createIdentity(passphrase);
|
|
70
|
+
// Import public key into a MajikContact
|
|
71
|
+
const contact = new MajikContact({
|
|
72
|
+
id: identity.id,
|
|
73
|
+
publicKey: identity.publicKey,
|
|
74
|
+
fingerprint: identity.fingerprint,
|
|
75
|
+
meta: { label: label || "" },
|
|
76
|
+
});
|
|
77
|
+
this.addOwnAccount(contact);
|
|
78
|
+
const backup = await KeyStore.exportIdentityBackup(identity.id);
|
|
79
|
+
return { id: identity.id, fingerprint: identity.fingerprint, backup };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Import an account from a backup blob (created with `exportIdentityBackup`) and unlock it.
|
|
83
|
+
*/
|
|
84
|
+
async importAccountFromBackup(backupBase64, passphrase, label) {
|
|
85
|
+
await KeyStore.importIdentityBackup(backupBase64);
|
|
86
|
+
// Unlock the imported identity
|
|
87
|
+
const decoded = JSON.parse(base64ToUtf8(backupBase64));
|
|
88
|
+
const id = decoded.id;
|
|
89
|
+
const identity = await KeyStore.unlockIdentity(id, passphrase);
|
|
90
|
+
const contact = new MajikContact({
|
|
91
|
+
id: identity.id,
|
|
92
|
+
publicKey: identity.publicKey,
|
|
93
|
+
fingerprint: identity.fingerprint,
|
|
94
|
+
meta: { label: label || "" },
|
|
95
|
+
});
|
|
96
|
+
if (!!this.getOwnAccountById(identity.id)) {
|
|
97
|
+
throw new Error("Account with the same ID already exists");
|
|
98
|
+
}
|
|
99
|
+
this.addOwnAccount(contact);
|
|
100
|
+
return { id: identity.id, fingerprint: identity.fingerprint };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Generate a BIP39 mnemonic for backup (12 words by default).
|
|
104
|
+
*/
|
|
105
|
+
generateMnemonic() {
|
|
106
|
+
return KeyStore.generateMnemonic();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Export a mnemonic-encrypted backup for an unlocked identity.
|
|
110
|
+
*/
|
|
111
|
+
async exportAccountMnemonicBackup(id, mnemonic) {
|
|
112
|
+
return KeyStore.exportIdentityMnemonicBackup(id, mnemonic);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Import an account from a mnemonic-encrypted backup blob and store it using `passphrase`.
|
|
116
|
+
*/
|
|
117
|
+
async importAccountFromMnemonicBackup(backupBase64, mnemonic, passphrase, label) {
|
|
118
|
+
const mJSON = {
|
|
119
|
+
id: backupBase64,
|
|
120
|
+
seed: seedStringToArray(mnemonic),
|
|
121
|
+
phrase: passphrase,
|
|
122
|
+
};
|
|
123
|
+
const importedIdentity = await MajikKey.fromMnemonicJSON(mJSON, passphrase, label);
|
|
124
|
+
await KeyStore.addMajikKey(importedIdentity);
|
|
125
|
+
const accountContact = await importedIdentity.toContact().toJSON();
|
|
126
|
+
const contact = MajikContact.fromJSON(accountContact);
|
|
127
|
+
if (!!this.getOwnAccountById(importedIdentity.id)) {
|
|
128
|
+
throw new Error("Account with the same ID already exists");
|
|
129
|
+
}
|
|
130
|
+
this.addOwnAccount(contact);
|
|
131
|
+
return {
|
|
132
|
+
id: importedIdentity.id,
|
|
133
|
+
fingerprint: importedIdentity.fingerprint,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Create a new account deterministically from `mnemonic` and store it encrypted with `passphrase`.
|
|
138
|
+
* Returns the created identity id (which equals fingerprint) and fingerprint.
|
|
139
|
+
*/
|
|
140
|
+
async createAccountFromMnemonic(mnemonic, passphrase, label) {
|
|
141
|
+
const newAccount = await MajikKey.create(mnemonic, passphrase, label);
|
|
142
|
+
await KeyStore.addMajikKey(newAccount);
|
|
143
|
+
const accountContact = await newAccount.toContact().toJSON();
|
|
144
|
+
const contact = MajikContact.fromJSON(accountContact);
|
|
145
|
+
this.addOwnAccount(contact);
|
|
146
|
+
return {
|
|
147
|
+
id: newAccount.id,
|
|
148
|
+
fingerprint: newAccount.fingerprint,
|
|
149
|
+
backup: newAccount.backup,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
addOwnAccount(account) {
|
|
153
|
+
if (!this.ownAccounts.has(account.id)) {
|
|
154
|
+
this.ownAccounts.set(account.id, account);
|
|
155
|
+
this.ownAccountsOrder.push(account.id);
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
if (!this.contactDirectory.hasContact(account.id)) {
|
|
159
|
+
this.contactDirectory.addContact(account);
|
|
160
|
+
}
|
|
161
|
+
if (!this.getActiveAccount()) {
|
|
162
|
+
this.setActiveAccount(account.id);
|
|
163
|
+
this.unlocked = true;
|
|
164
|
+
}
|
|
165
|
+
this.emit("new-account", account);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
// ignore if contact can't be added
|
|
169
|
+
}
|
|
170
|
+
this.scheduleAutosave();
|
|
171
|
+
}
|
|
172
|
+
listOwnAccounts(majikahOnly = false) {
|
|
173
|
+
let userAccounts = this.ownAccountsOrder
|
|
174
|
+
.map((id) => this.ownAccounts.get(id))
|
|
175
|
+
.filter((c) => !!c);
|
|
176
|
+
if (majikahOnly) {
|
|
177
|
+
userAccounts = userAccounts.filter((acct) => this.isContactMajikahRegistered(acct.id));
|
|
178
|
+
}
|
|
179
|
+
return userAccounts;
|
|
180
|
+
}
|
|
181
|
+
getOwnAccountById(id) {
|
|
182
|
+
return this.ownAccounts.get(id);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Set an active account (moves it to index 0)
|
|
186
|
+
*/
|
|
187
|
+
async setActiveAccount(id, bypassIdentity = false) {
|
|
188
|
+
if (!this.ownAccounts.has(id))
|
|
189
|
+
return false;
|
|
190
|
+
if (!bypassIdentity) {
|
|
191
|
+
// Ensure identity is unlocked
|
|
192
|
+
try {
|
|
193
|
+
await this.ensureIdentityUnlocked(id);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
console.warn("Failed to unlock account:", err);
|
|
197
|
+
return false; // don't set as active if unlock fails
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const previousActive = this.getActiveAccount()?.id;
|
|
201
|
+
// Remove ID from current position
|
|
202
|
+
const index = this.ownAccountsOrder.indexOf(id);
|
|
203
|
+
if (index > -1)
|
|
204
|
+
this.ownAccountsOrder.splice(index, 1);
|
|
205
|
+
// Add to the front
|
|
206
|
+
this.ownAccountsOrder.unshift(id);
|
|
207
|
+
this.scheduleAutosave();
|
|
208
|
+
// 🔔 Emit the active account changed event
|
|
209
|
+
if (previousActive !== id) {
|
|
210
|
+
const newActive = this.getActiveAccount();
|
|
211
|
+
this.emit("active-account-change", newActive, previousActive);
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
getActiveAccount() {
|
|
216
|
+
if (this.ownAccountsOrder.length === 0)
|
|
217
|
+
return null;
|
|
218
|
+
return this.ownAccounts.get(this.ownAccountsOrder[0]) || null;
|
|
219
|
+
}
|
|
220
|
+
isAccountActive(id) {
|
|
221
|
+
if (!this.ownAccounts.has(id))
|
|
222
|
+
return false;
|
|
223
|
+
if (this.ownAccountsOrder.length === 0)
|
|
224
|
+
return false;
|
|
225
|
+
return this.ownAccountsOrder[0] === id;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Remove an own account from the in-memory registry.
|
|
229
|
+
*/
|
|
230
|
+
removeOwnAccount(id) {
|
|
231
|
+
if (!this.ownAccounts.has(id))
|
|
232
|
+
return false;
|
|
233
|
+
this.ownAccounts.delete(id);
|
|
234
|
+
const idx = this.ownAccountsOrder.indexOf(id);
|
|
235
|
+
if (idx > -1)
|
|
236
|
+
this.ownAccountsOrder.splice(idx, 1);
|
|
237
|
+
this.removeContact(id);
|
|
238
|
+
// remove cached envelopes addressed to this identity
|
|
239
|
+
this.envelopeCache.deleteByFingerprint(id).catch((error) => {
|
|
240
|
+
console.warn("Account not found in cache: ", error);
|
|
241
|
+
});
|
|
242
|
+
this.emit("removed-account", id);
|
|
243
|
+
this.scheduleAutosave();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Retrieve a contact from the directory by ID.
|
|
248
|
+
* Validates that the input is a non-empty string.
|
|
249
|
+
* Returns the MajikContact instance or null if not found.
|
|
250
|
+
*/
|
|
251
|
+
getContactByID(id) {
|
|
252
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
253
|
+
throw new Error("Invalid contact ID: must be a non-empty string");
|
|
254
|
+
}
|
|
255
|
+
if (!this.contactDirectory.hasContact(id)) {
|
|
256
|
+
return null; // Not found
|
|
257
|
+
}
|
|
258
|
+
return this.contactDirectory.getContact(id) ?? null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Retrieve a contact from the directory by its public key.
|
|
262
|
+
* Validates that the input is a non-empty string.
|
|
263
|
+
* Returns the MajikContact instance or null if not found.
|
|
264
|
+
*/
|
|
265
|
+
async getContactByPublicKey(id) {
|
|
266
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
267
|
+
throw new Error("Invalid contact ID: must be a non-empty string");
|
|
268
|
+
}
|
|
269
|
+
return ((await this.contactDirectory.getContactByPublicKeyBase64(id)) ?? null);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Returns a JSON string representation of a contact
|
|
273
|
+
* suitable for sharing.
|
|
274
|
+
*/
|
|
275
|
+
async exportContactAsJSON(contactId) {
|
|
276
|
+
const contact = this.contactDirectory.getContact(contactId);
|
|
277
|
+
if (!contact)
|
|
278
|
+
return null;
|
|
279
|
+
// Support raw-key wrappers produced by the Stablelib provider
|
|
280
|
+
let publicKeyBase64;
|
|
281
|
+
const anyPub = contact.publicKey;
|
|
282
|
+
if (anyPub && anyPub.raw instanceof Uint8Array) {
|
|
283
|
+
publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
|
|
287
|
+
publicKeyBase64 = arrayBufferToBase64(raw);
|
|
288
|
+
}
|
|
289
|
+
const payload = {
|
|
290
|
+
id: contact.id,
|
|
291
|
+
label: contact.meta?.label || "",
|
|
292
|
+
publicKey: publicKeyBase64,
|
|
293
|
+
fingerprint: contact.fingerprint,
|
|
294
|
+
};
|
|
295
|
+
return JSON.stringify(payload, null, 2); // pretty-print for easier copy-paste
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Returns a compact base64 string for sharing a contact.
|
|
299
|
+
* Encodes JSON payload into base64.
|
|
300
|
+
*/
|
|
301
|
+
async exportContactAsString(contactId) {
|
|
302
|
+
const json = await this.exportContactAsJSON(contactId);
|
|
303
|
+
if (!json)
|
|
304
|
+
return null;
|
|
305
|
+
return utf8ToBase64(json);
|
|
306
|
+
}
|
|
307
|
+
/* ================================
|
|
308
|
+
* Contact Management
|
|
309
|
+
* ================================ */
|
|
310
|
+
async importContactFromJSON(jsonStr) {
|
|
311
|
+
try {
|
|
312
|
+
const data = JSON.parse(jsonStr);
|
|
313
|
+
if (!data.id || !data.publicKey || !data.fingerprint)
|
|
314
|
+
return {
|
|
315
|
+
success: false,
|
|
316
|
+
message: "Invalid contact JSON",
|
|
317
|
+
};
|
|
318
|
+
// If publicKey is a base64 string, import it
|
|
319
|
+
let publicKeyPromise;
|
|
320
|
+
if (typeof data.publicKey === "string") {
|
|
321
|
+
try {
|
|
322
|
+
const rawBuffer = base64ToArrayBuffer(data.publicKey);
|
|
323
|
+
try {
|
|
324
|
+
publicKeyPromise = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
// Fallback: create a raw-key wrapper when the browser does not support the namedCurve
|
|
328
|
+
publicKeyPromise = { raw: new Uint8Array(rawBuffer) };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
console.error("Failed to parse publicKey base64", e);
|
|
333
|
+
return {
|
|
334
|
+
success: false,
|
|
335
|
+
message: "Failed to parse publicKey base64",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// assume already a CryptoKey
|
|
341
|
+
publicKeyPromise = await Promise.resolve(data.publicKey);
|
|
342
|
+
}
|
|
343
|
+
const contact = new MajikContact({
|
|
344
|
+
id: data.id,
|
|
345
|
+
publicKey: publicKeyPromise,
|
|
346
|
+
fingerprint: data.fingerprint,
|
|
347
|
+
meta: { label: data.label },
|
|
348
|
+
});
|
|
349
|
+
this.addContact(contact);
|
|
350
|
+
return {
|
|
351
|
+
success: true,
|
|
352
|
+
message: "Contact imported successfully",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
console.error("Failed to import contact from JSON:", err);
|
|
357
|
+
return {
|
|
358
|
+
success: false,
|
|
359
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* List cached envelopes stored in the local cache (most recent first).
|
|
365
|
+
* Returns objects: { id, envelope, timestamp, source }
|
|
366
|
+
*/
|
|
367
|
+
async listCachedEnvelopes(offset = 0, limit = 50) {
|
|
368
|
+
return await this.envelopeCache.listRecent(offset, limit);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Clear cached envelopes stored in the local cache.
|
|
372
|
+
*/
|
|
373
|
+
async clearCachedEnvelopes() {
|
|
374
|
+
const response = await this.envelopeCache.clear();
|
|
375
|
+
if (!response?.success) {
|
|
376
|
+
throw new Error(response.message);
|
|
377
|
+
}
|
|
378
|
+
this.scheduleAutosave();
|
|
379
|
+
return response.success;
|
|
380
|
+
}
|
|
381
|
+
async hasOwnIdentity(fingerprint) {
|
|
382
|
+
return await KeyStore.hasIdentity(fingerprint);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Attempt to decrypt a given envelope and return the plaintext string.
|
|
386
|
+
* Will prompt to unlock identity if necessary.
|
|
387
|
+
*/
|
|
388
|
+
async decryptEnvelope(envelope, bypassIdentity = false) {
|
|
389
|
+
if (envelope.isGroup()) {
|
|
390
|
+
// Group message - try all own accounts
|
|
391
|
+
const ownAccounts = this.listOwnAccounts();
|
|
392
|
+
if (ownAccounts.length === 0) {
|
|
393
|
+
throw new Error("No own accounts available to decrypt group message");
|
|
394
|
+
}
|
|
395
|
+
for (const ownAccount of ownAccounts) {
|
|
396
|
+
try {
|
|
397
|
+
const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
|
|
398
|
+
const decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, ownAccount.fingerprint);
|
|
399
|
+
// Decompress if needed
|
|
400
|
+
let plaintext = decrypted;
|
|
401
|
+
if (decrypted.startsWith("mjkcmp:")) {
|
|
402
|
+
plaintext = (await MajikCompressor.decompress("plaintext", decrypted));
|
|
403
|
+
}
|
|
404
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
405
|
+
? window.location.hostname
|
|
406
|
+
: "extension");
|
|
407
|
+
return plaintext;
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
// This account can't decrypt, try next
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
throw new Error("None of your accounts can decrypt this group message");
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
// Solo message - original logic
|
|
418
|
+
const fingerprint = envelope.extractFingerprint();
|
|
419
|
+
const ownAccount = this.listOwnAccounts().find((a) => a.fingerprint === fingerprint);
|
|
420
|
+
if (!ownAccount) {
|
|
421
|
+
throw new Error("No matching account to decrypt this envelope");
|
|
422
|
+
}
|
|
423
|
+
const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
|
|
424
|
+
const decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
|
|
425
|
+
let plaintext = decrypted;
|
|
426
|
+
if (decrypted.startsWith("mjkcmp:")) {
|
|
427
|
+
plaintext = (await MajikCompressor.decompress("plaintext", decrypted));
|
|
428
|
+
}
|
|
429
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
430
|
+
? window.location.hostname
|
|
431
|
+
: "extension");
|
|
432
|
+
return plaintext;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async importContactFromString(base64Str) {
|
|
436
|
+
const jsonStr = base64ToUtf8(base64Str);
|
|
437
|
+
const isImportSuccess = await this.importContactFromJSON(jsonStr);
|
|
438
|
+
if (!isImportSuccess.success)
|
|
439
|
+
throw new Error(isImportSuccess.message);
|
|
440
|
+
}
|
|
441
|
+
addContact(contact) {
|
|
442
|
+
this.contactDirectory.addContact(contact);
|
|
443
|
+
this.emit("new-contact", contact);
|
|
444
|
+
this.scheduleAutosave();
|
|
445
|
+
}
|
|
446
|
+
removeContact(id) {
|
|
447
|
+
const removalStatus = this.contactDirectory.removeContact(id);
|
|
448
|
+
if (!removalStatus.success) {
|
|
449
|
+
throw new Error(removalStatus.message);
|
|
450
|
+
}
|
|
451
|
+
this.emit("removed-contact", id);
|
|
452
|
+
this.scheduleAutosave();
|
|
453
|
+
}
|
|
454
|
+
updateContactMeta(id, meta) {
|
|
455
|
+
this.contactDirectory.updateContactMeta(id, meta);
|
|
456
|
+
this.scheduleAutosave();
|
|
457
|
+
}
|
|
458
|
+
blockContact(id) {
|
|
459
|
+
this.contactDirectory.blockContact(id);
|
|
460
|
+
this.scheduleAutosave();
|
|
461
|
+
}
|
|
462
|
+
unblockContact(id) {
|
|
463
|
+
this.contactDirectory.unblockContact(id);
|
|
464
|
+
this.scheduleAutosave();
|
|
465
|
+
}
|
|
466
|
+
listContacts(all = true, majikahOnly = false) {
|
|
467
|
+
const contacts = this.contactDirectory.listContacts(true, majikahOnly);
|
|
468
|
+
if (all) {
|
|
469
|
+
return contacts;
|
|
470
|
+
}
|
|
471
|
+
const userAccounts = this.listOwnAccounts(majikahOnly);
|
|
472
|
+
const userAccountIds = new Set(userAccounts.map((a) => a.id));
|
|
473
|
+
return contacts.filter((contact) => !userAccountIds.has(contact.id));
|
|
474
|
+
}
|
|
475
|
+
isContactMajikahRegistered(id) {
|
|
476
|
+
return this.contactDirectory.isMajikahRegistered(id);
|
|
477
|
+
}
|
|
478
|
+
isContactMajikahIdentityChecked(id) {
|
|
479
|
+
return this.contactDirectory.isMajikahIdentityChecked(id);
|
|
480
|
+
}
|
|
481
|
+
setContactMajikahStatus(id, status) {
|
|
482
|
+
this.contactDirectory.setMajikahStatus(id, status);
|
|
483
|
+
this.scheduleAutosave();
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Update the passphrase for an identity.
|
|
487
|
+
* - `id` defaults to the current active account if not provided.
|
|
488
|
+
* - Throws if no active account exists or passphrase is invalid.
|
|
489
|
+
*/
|
|
490
|
+
async updatePassphrase(currentPassphrase, newPassphrase, id) {
|
|
491
|
+
// Determine target account
|
|
492
|
+
const targetAccount = id
|
|
493
|
+
? this.getOwnAccountById(id)
|
|
494
|
+
: this.getActiveAccount();
|
|
495
|
+
if (!targetAccount) {
|
|
496
|
+
throw new Error("No target account specified and no active account available");
|
|
497
|
+
}
|
|
498
|
+
// Delegate to KeyStore
|
|
499
|
+
await KeyStore.updatePassphrase(targetAccount.id, currentPassphrase, newPassphrase);
|
|
500
|
+
// Optionally emit an event or autosave
|
|
501
|
+
this.scheduleAutosave();
|
|
502
|
+
}
|
|
503
|
+
/* ================================
|
|
504
|
+
* Encryption / Decryption
|
|
505
|
+
* ================================ */
|
|
506
|
+
/**
|
|
507
|
+
* Encrypts a plaintext message for a single recipient (solo message)
|
|
508
|
+
* Returns a MessageEnvelope instance and caches it automatically.
|
|
509
|
+
*/
|
|
510
|
+
async encryptSoloMessage(toId, plaintext, cache = true) {
|
|
511
|
+
const contact = this.contactDirectory.getContact(toId);
|
|
512
|
+
if (!contact)
|
|
513
|
+
throw new Error(`No contact with id "${toId}"`);
|
|
514
|
+
const payload = await EncryptionEngine.encryptSoloMessage(plaintext, contact.publicKey);
|
|
515
|
+
const payloadJSON = JSON.stringify(payload);
|
|
516
|
+
const encoder = new TextEncoder();
|
|
517
|
+
const payloadBytes = encoder.encode(payloadJSON);
|
|
518
|
+
// Envelope: [version byte][fingerprint][payload]
|
|
519
|
+
const versionByte = new Uint8Array([1]);
|
|
520
|
+
const fingerprintBytes = new Uint8Array(base64ToArrayBuffer(contact.fingerprint));
|
|
521
|
+
const blob = new Uint8Array(versionByte.length + fingerprintBytes.length + payloadBytes.length);
|
|
522
|
+
blob.set(versionByte, 0);
|
|
523
|
+
blob.set(fingerprintBytes, versionByte.length);
|
|
524
|
+
blob.set(payloadBytes, versionByte.length + fingerprintBytes.length);
|
|
525
|
+
const envelope = new MessageEnvelope(blob.buffer);
|
|
526
|
+
if (!!cache) {
|
|
527
|
+
// Cache envelope
|
|
528
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
529
|
+
? window.location.hostname
|
|
530
|
+
: "extension");
|
|
531
|
+
}
|
|
532
|
+
this.scheduleAutosave();
|
|
533
|
+
this.emit("envelope", envelope);
|
|
534
|
+
return envelope;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Encrypts a plaintext message for a group of recipients.
|
|
538
|
+
* Returns a unified group MessageEnvelope instance.
|
|
539
|
+
*/
|
|
540
|
+
async encryptGroupMessage(recipientIds, plaintext, cache = true) {
|
|
541
|
+
if (!recipientIds.length) {
|
|
542
|
+
throw new Error("No recipients provided");
|
|
543
|
+
}
|
|
544
|
+
// Resolve recipients and their keys
|
|
545
|
+
const recipients = recipientIds.map((id) => {
|
|
546
|
+
const contact = this.contactDirectory.getContact(id);
|
|
547
|
+
if (!contact)
|
|
548
|
+
throw new Error(`No contact with id "${id}"`);
|
|
549
|
+
return {
|
|
550
|
+
id: contact.id,
|
|
551
|
+
publicKey: contact.publicKey,
|
|
552
|
+
fingerprint: contact.fingerprint,
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
// 🔐 Encrypt once for all recipients
|
|
556
|
+
const payload = await EncryptionEngine.encryptGroupMessage(plaintext, recipients);
|
|
557
|
+
// Serialize payload
|
|
558
|
+
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
559
|
+
// Envelope structure: [version byte][sender fingerprint][payload bytes]
|
|
560
|
+
const versionByte = new Uint8Array([2]);
|
|
561
|
+
// // Use sender fingerprint for group envelope
|
|
562
|
+
// const activeAccount = this.getActiveAccount();
|
|
563
|
+
// if (!activeAccount) throw new Error("No active account to send from");
|
|
564
|
+
// const fingerprintBytes = new Uint8Array(
|
|
565
|
+
// base64ToArrayBuffer(activeAccount.fingerprint),
|
|
566
|
+
// );
|
|
567
|
+
// ✅ Use a special marker instead of a specific fingerprint
|
|
568
|
+
// Option 1: All zeros to indicate "multi-recipient"
|
|
569
|
+
const markerBytes = new Uint8Array(32).fill(0);
|
|
570
|
+
// Combine all parts into a single Uint8Array
|
|
571
|
+
const blob = new Uint8Array(versionByte.length + markerBytes.length + payloadBytes.length);
|
|
572
|
+
blob.set(versionByte, 0);
|
|
573
|
+
blob.set(markerBytes, versionByte.length);
|
|
574
|
+
blob.set(payloadBytes, versionByte.length + markerBytes.length);
|
|
575
|
+
// Wrap as MessageEnvelope
|
|
576
|
+
const envelope = new MessageEnvelope(blob.buffer);
|
|
577
|
+
if (!!cache) {
|
|
578
|
+
// Cache envelope
|
|
579
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
580
|
+
? window.location.hostname
|
|
581
|
+
: "extension");
|
|
582
|
+
}
|
|
583
|
+
this.scheduleAutosave();
|
|
584
|
+
this.emit("envelope", envelope);
|
|
585
|
+
return envelope;
|
|
586
|
+
}
|
|
587
|
+
async sendMessage(recipients, plaintext) {
|
|
588
|
+
if (recipients.length === 0 || !recipients) {
|
|
589
|
+
throw new Error("No recipients provided. At least one recipient is required.");
|
|
590
|
+
}
|
|
591
|
+
if (recipients.length === 1) {
|
|
592
|
+
return await this.encryptSoloMessage(recipients[0], plaintext);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
return await this.encryptGroupMessage(recipients, plaintext);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/* ================================
|
|
599
|
+
* High-Level DOM Wrapper
|
|
600
|
+
* ================================ */
|
|
601
|
+
/**
|
|
602
|
+
* Create a new MajikMessageChat with compression, then encrypt it.
|
|
603
|
+
* Returns the scanner-ready string containing the encrypted compressed message.
|
|
604
|
+
*
|
|
605
|
+
* Flow: Plaintext → Compress (MajikMessageChat) → Encrypt (EncryptionEngine) → Scanner String
|
|
606
|
+
*/
|
|
607
|
+
async createEncryptedMajikMessageChat(account, recipients, plaintext, expiresInMs) {
|
|
608
|
+
if (!plaintext?.trim()) {
|
|
609
|
+
throw new Error("No text provided to encrypt.");
|
|
610
|
+
}
|
|
611
|
+
if (!recipients || recipients.length === 0) {
|
|
612
|
+
const firstOwn = this.listOwnAccounts()[0];
|
|
613
|
+
if (!firstOwn) {
|
|
614
|
+
throw new Error("No own account available for encryption.");
|
|
615
|
+
}
|
|
616
|
+
recipients = [firstOwn.id];
|
|
617
|
+
}
|
|
618
|
+
if (!account) {
|
|
619
|
+
throw new Error("No active account available to send message");
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
// Step 1: Create MajikMessageChat (compresses plaintext)
|
|
623
|
+
const messageChat = await MajikMessageChat.create(account, plaintext, recipients, expiresInMs);
|
|
624
|
+
// Step 2: Get compressed message for encryption
|
|
625
|
+
const compressedMessage = messageChat.getCompressedMessage();
|
|
626
|
+
// Step 3: Encrypt the compressed message using EncryptionEngine
|
|
627
|
+
let encryptedPayload;
|
|
628
|
+
if (recipients.length === 1) {
|
|
629
|
+
// Solo encryption
|
|
630
|
+
const contact = this.contactDirectory.getContact(recipients[0]);
|
|
631
|
+
if (!contact) {
|
|
632
|
+
throw new Error(`No contact found for recipient: ${recipients[0]}`);
|
|
633
|
+
}
|
|
634
|
+
encryptedPayload = await EncryptionEngine.encryptSoloMessage(compressedMessage, contact.publicKey);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
// Group encryption
|
|
638
|
+
const recipientData = recipients.map((id) => {
|
|
639
|
+
const contact = this.contactDirectory.getContact(id);
|
|
640
|
+
if (!contact) {
|
|
641
|
+
throw new Error(`No contact found for recipient: ${id}`);
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
id: contact.id,
|
|
645
|
+
publicKey: contact.publicKey,
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
encryptedPayload = await EncryptionEngine.encryptGroupMessage(compressedMessage, recipientData);
|
|
649
|
+
}
|
|
650
|
+
// Step 4: Convert encrypted payload to base64 string
|
|
651
|
+
const payloadJSON = JSON.stringify(encryptedPayload);
|
|
652
|
+
const payloadBase64 = utf8ToBase64(payloadJSON);
|
|
653
|
+
// Step 5: Create scanner string with MAJIK prefix
|
|
654
|
+
const scannerString = `${MessageEnvelope.PREFIX}:${payloadBase64}`;
|
|
655
|
+
// Step 6: Update the messageChat with encrypted payload for storage
|
|
656
|
+
messageChat.setMessage(payloadJSON); // Store encrypted version
|
|
657
|
+
return { messageChat, scannerString };
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
this.emit("error", err, { context: "createEncryptedMajikMessageChat" });
|
|
661
|
+
throw err;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Decrypt and decompress a MajikMessageChat message.
|
|
666
|
+
*
|
|
667
|
+
* Flow: Encrypted Payload → Decrypt (EncryptionEngine) → Decompress (MajikCompressor) → Plaintext
|
|
668
|
+
*/
|
|
669
|
+
async decryptMajikMessageChat(encryptedPayload, recipientId) {
|
|
670
|
+
const recipient = recipientId
|
|
671
|
+
? this.getOwnAccountById(recipientId)
|
|
672
|
+
: this.getActiveAccount();
|
|
673
|
+
if (!recipient) {
|
|
674
|
+
throw new Error("No recipient account found for decryption");
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
// Step 1: Ensure identity is unlocked
|
|
678
|
+
const privateKey = await this.ensureIdentityUnlocked(recipient.id);
|
|
679
|
+
// Step 2: Parse the encrypted payload
|
|
680
|
+
const payload = JSON.parse(encryptedPayload);
|
|
681
|
+
// Step 3: Decrypt using EncryptionEngine
|
|
682
|
+
let decryptedCompressed;
|
|
683
|
+
if (payload.keys) {
|
|
684
|
+
// Group message
|
|
685
|
+
decryptedCompressed = await EncryptionEngine.decryptGroupMessage(payload, privateKey, recipient.fingerprint);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// Solo message
|
|
689
|
+
decryptedCompressed = await EncryptionEngine.decryptSoloMessage(payload, privateKey);
|
|
690
|
+
}
|
|
691
|
+
// Step 4: Decompress the message
|
|
692
|
+
const plaintext = await MajikCompressor.decompressString(decryptedCompressed);
|
|
693
|
+
return plaintext;
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
this.emit("error", err, { context: "decryptMajikMessageChat" });
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Encrypts currently selected text in the browser DOM for given recipients.
|
|
702
|
+
* If `recipients` is empty, defaults to the first own account.
|
|
703
|
+
* Returns the fully serialized base64 envelope string for the scanner.
|
|
704
|
+
*/
|
|
705
|
+
async encryptSelectedTextForScanner(recipients = []) {
|
|
706
|
+
// Delegate to textarea-agnostic implementation
|
|
707
|
+
const plaintext = window.getSelection()?.toString().trim() ?? "";
|
|
708
|
+
return await this.encryptTextForScanner(plaintext, recipients);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Encrypt a provided plaintext string and return a serialized MajikMessage envelope string.
|
|
712
|
+
* Supports single or multiple recipients. Safe to call from background contexts.
|
|
713
|
+
*/
|
|
714
|
+
async encryptTextForScanner(plaintext, recipients = [], cache = true) {
|
|
715
|
+
if (!plaintext?.trim()) {
|
|
716
|
+
console.warn("No text provided to encrypt.");
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
// Determine recipients: default to first own account if none provided
|
|
721
|
+
if (recipients.length === 0) {
|
|
722
|
+
const firstOwn = this.listOwnAccounts()[0];
|
|
723
|
+
if (!firstOwn)
|
|
724
|
+
throw new Error("No own account available for encryption.");
|
|
725
|
+
recipients = [firstOwn.id];
|
|
726
|
+
}
|
|
727
|
+
let envelope;
|
|
728
|
+
if (recipients.length === 1) {
|
|
729
|
+
// Single recipient → solo message
|
|
730
|
+
envelope = await this.encryptSoloMessage(recipients[0], plaintext, cache);
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
// Multiple recipients → unified group message
|
|
734
|
+
envelope = await this.encryptGroupMessage(recipients, plaintext, cache);
|
|
735
|
+
}
|
|
736
|
+
// Convert envelope to base64 for scanner
|
|
737
|
+
const envelopeBase64 = arrayBufferToBase64(envelope.raw);
|
|
738
|
+
return `${MessageEnvelope.PREFIX}:${envelopeBase64}`;
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
this.emit("error", err, { context: "encryptTextForScanner" });
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Encrypt text for a given target (label or ID).
|
|
747
|
+
* - If target is self or not found, just encrypt normally.
|
|
748
|
+
* - If target is another contact, always make a group message including self + target.
|
|
749
|
+
*/
|
|
750
|
+
async encryptForTarget(target, // can be label or id
|
|
751
|
+
plaintext) {
|
|
752
|
+
const activeAccount = this.getActiveAccount();
|
|
753
|
+
if (!activeAccount)
|
|
754
|
+
throw new Error("No active account available");
|
|
755
|
+
// Try to find contact by ID first
|
|
756
|
+
let contact = this.listContacts(false).find((c) => c.id === target);
|
|
757
|
+
// If not found by ID, try by label
|
|
758
|
+
if (!contact) {
|
|
759
|
+
contact = this.listContacts(false).find((c) => c.meta?.label === target);
|
|
760
|
+
}
|
|
761
|
+
// If still not found or it's self → solo encryption
|
|
762
|
+
if (!contact || contact.id === activeAccount.id) {
|
|
763
|
+
return this.encryptTextForScanner(plaintext, [activeAccount.id]);
|
|
764
|
+
}
|
|
765
|
+
// Otherwise → group with self + target contact
|
|
766
|
+
return this.encryptTextForScanner(plaintext, [
|
|
767
|
+
activeAccount.id,
|
|
768
|
+
contact.id,
|
|
769
|
+
]);
|
|
770
|
+
}
|
|
771
|
+
/* ================================
|
|
772
|
+
* DOM Scanning
|
|
773
|
+
* ================================ */
|
|
774
|
+
scanDOM(rootNode) {
|
|
775
|
+
this.scanner.scanDOM(rootNode);
|
|
776
|
+
}
|
|
777
|
+
startDOMObserver(rootNode) {
|
|
778
|
+
this.scanner.startDOMObserver(rootNode);
|
|
779
|
+
}
|
|
780
|
+
stopDOMObserver() {
|
|
781
|
+
this.scanner.stopDOMObserver();
|
|
782
|
+
}
|
|
783
|
+
/* ================================
|
|
784
|
+
* Event Handling
|
|
785
|
+
* ================================ */
|
|
786
|
+
on(event, callback) {
|
|
787
|
+
this.listeners.get(event)?.push(callback);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Remove a previously registered event listener.
|
|
791
|
+
* If `callback` is omitted, all listeners for the event are removed.
|
|
792
|
+
*/
|
|
793
|
+
off(event, callback) {
|
|
794
|
+
const callbacks = this.listeners.get(event);
|
|
795
|
+
if (!callbacks || callbacks.length === 0)
|
|
796
|
+
return;
|
|
797
|
+
if (callback) {
|
|
798
|
+
// Remove only the specific callback
|
|
799
|
+
const index = callbacks.indexOf(callback);
|
|
800
|
+
if (index !== -1)
|
|
801
|
+
callbacks.splice(index, 1);
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
// Remove all callbacks for this event
|
|
805
|
+
this.listeners.set(event, []);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
emit(event, ...args) {
|
|
809
|
+
this.listeners.get(event)?.forEach((cb) => cb(...args));
|
|
810
|
+
}
|
|
811
|
+
/* ================================
|
|
812
|
+
* Envelope Handling
|
|
813
|
+
* ================================ */
|
|
814
|
+
async handleEnvelope(envelope) {
|
|
815
|
+
// Skip if already cached
|
|
816
|
+
const cached = await this.envelopeCache.get(envelope);
|
|
817
|
+
if (cached)
|
|
818
|
+
return;
|
|
819
|
+
const fingerprint = envelope.extractFingerprint();
|
|
820
|
+
// Check if this is a group message (all zeros or special marker)
|
|
821
|
+
const isGroupMessage = envelope.isGroup();
|
|
822
|
+
if (isGroupMessage) {
|
|
823
|
+
// For group messages, try all own accounts until one works
|
|
824
|
+
const ownAccounts = this.listOwnAccounts();
|
|
825
|
+
if (ownAccounts.length === 0) {
|
|
826
|
+
this.emit("untrusted", envelope);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
let decrypted = null;
|
|
830
|
+
let successfulAccount = null;
|
|
831
|
+
for (const ownAccount of ownAccounts) {
|
|
832
|
+
try {
|
|
833
|
+
const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
|
|
834
|
+
// Try to decrypt with this account
|
|
835
|
+
decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, ownAccount.fingerprint);
|
|
836
|
+
successfulAccount = ownAccount;
|
|
837
|
+
break; // Success! Stop trying other accounts
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
// This account doesn't have access, try next one
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (!decrypted || !successfulAccount) {
|
|
845
|
+
this.emit("untrusted", envelope);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
// Cache and emit
|
|
849
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
850
|
+
? window.location.hostname
|
|
851
|
+
: "extension");
|
|
852
|
+
this.scheduleAutosave();
|
|
853
|
+
this.emit("message", decrypted, envelope, successfulAccount);
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
// Solo message - original logic
|
|
857
|
+
const ownAccount = this.listOwnAccounts().find((a) => a.fingerprint === fingerprint);
|
|
858
|
+
if (!ownAccount) {
|
|
859
|
+
this.emit("untrusted", envelope);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
|
|
864
|
+
const decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
|
|
865
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
866
|
+
? window.location.hostname
|
|
867
|
+
: "extension");
|
|
868
|
+
this.scheduleAutosave();
|
|
869
|
+
this.emit("message", decrypted, envelope, ownAccount);
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
this.emit("error", err, { envelope });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Ensure an identity is unlocked. If locked, prompt the user for a passphrase.
|
|
878
|
+
* `promptFn` can be provided to show a custom UI: either synchronous returning string
|
|
879
|
+
* or async Promise<string>. If omitted, falls back to `window.prompt`.
|
|
880
|
+
*/
|
|
881
|
+
async ensureIdentityUnlocked(id, promptFn) {
|
|
882
|
+
try {
|
|
883
|
+
return await KeyStore.getPrivateKey(id);
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
// If KeyStore indicates unlocking is required, prompt the user
|
|
887
|
+
const needsUnlock = err instanceof Error &&
|
|
888
|
+
/must be unlocked|unlockIdentity/.test(err.message);
|
|
889
|
+
if (!needsUnlock)
|
|
890
|
+
throw err;
|
|
891
|
+
// Ask for passphrase
|
|
892
|
+
let passphrase = null;
|
|
893
|
+
if (promptFn) {
|
|
894
|
+
const res = promptFn(id);
|
|
895
|
+
passphrase = typeof res === "string" ? res : await res;
|
|
896
|
+
}
|
|
897
|
+
else if (KeyStore.onUnlockRequested) {
|
|
898
|
+
const res = KeyStore.onUnlockRequested(id);
|
|
899
|
+
passphrase = typeof res === "string" ? res : await res;
|
|
900
|
+
}
|
|
901
|
+
else if (typeof window !== "undefined" && window.prompt) {
|
|
902
|
+
passphrase = window.prompt("Enter passphrase to unlock identity:", "");
|
|
903
|
+
}
|
|
904
|
+
if (!passphrase) {
|
|
905
|
+
this.unlocked = false;
|
|
906
|
+
throw new Error("Unlock cancelled");
|
|
907
|
+
}
|
|
908
|
+
// Attempt to unlock
|
|
909
|
+
await KeyStore.unlockIdentity(id, passphrase);
|
|
910
|
+
this.unlocked = true;
|
|
911
|
+
return await KeyStore.getPrivateKey(id);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
isUnlocked() {
|
|
915
|
+
return this.unlocked;
|
|
916
|
+
}
|
|
917
|
+
async isPassphraseValid(passphrase, id) {
|
|
918
|
+
const target = id ? this.getOwnAccountById(id) : this.getActiveAccount();
|
|
919
|
+
if (!target)
|
|
920
|
+
return false;
|
|
921
|
+
return KeyStore.isPassphraseValid(target.id, passphrase);
|
|
922
|
+
}
|
|
923
|
+
async toJSON() {
|
|
924
|
+
const finalJSON = {
|
|
925
|
+
id: this.id,
|
|
926
|
+
contacts: await this.contactDirectory.toJSON(),
|
|
927
|
+
envelopeCache: this.envelopeCache.toJSON(),
|
|
928
|
+
};
|
|
929
|
+
// Serialize own accounts (preserve order)
|
|
930
|
+
try {
|
|
931
|
+
const ownAccountsArr = [];
|
|
932
|
+
for (const id of this.ownAccountsOrder) {
|
|
933
|
+
const acct = this.ownAccounts.get(id);
|
|
934
|
+
if (!acct)
|
|
935
|
+
continue;
|
|
936
|
+
ownAccountsArr.push(await acct.toJSON());
|
|
937
|
+
}
|
|
938
|
+
finalJSON.ownAccounts = {
|
|
939
|
+
accounts: ownAccountsArr,
|
|
940
|
+
order: [...this.ownAccountsOrder],
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
catch (e) {
|
|
944
|
+
// ignore serialization errors for own accounts
|
|
945
|
+
console.warn("Failed to serialize ownAccounts:", e);
|
|
946
|
+
}
|
|
947
|
+
// include optional PIN hash
|
|
948
|
+
finalJSON.pinHash = this.pinHash || null;
|
|
949
|
+
return finalJSON;
|
|
950
|
+
}
|
|
951
|
+
static async fromJSON(json) {
|
|
952
|
+
const newDirectory = new MajikContactDirectory();
|
|
953
|
+
const parsedContacts = await newDirectory.fromJSON(json.contacts);
|
|
954
|
+
const parsedEnvelopeCache = EnvelopeCache.fromJSON(json.envelopeCache);
|
|
955
|
+
const parsedInstance = new this({
|
|
956
|
+
contactDirectory: parsedContacts,
|
|
957
|
+
envelopeCache: parsedEnvelopeCache,
|
|
958
|
+
keyStore: KeyStore,
|
|
959
|
+
}, json.id);
|
|
960
|
+
// Restore ownAccounts if present
|
|
961
|
+
try {
|
|
962
|
+
if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
|
|
963
|
+
for (const acct of json.ownAccounts.accounts) {
|
|
964
|
+
try {
|
|
965
|
+
const raw = base64ToArrayBuffer(acct.publicKeyBase64);
|
|
966
|
+
const publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
|
|
967
|
+
const contact = MajikContact.create(acct.id, publicKey, acct.fingerprint, acct.meta);
|
|
968
|
+
parsedInstance.ownAccounts.set(contact.id, contact);
|
|
969
|
+
}
|
|
970
|
+
catch (e) {
|
|
971
|
+
// SubtleCrypto may not support importing X25519 keys in some environments.
|
|
972
|
+
// This is non-fatal: we fall back to raw-key wrappers elsewhere.
|
|
973
|
+
console.info("Fallback restoring own account (using raw-key wrapper)", acct.id, e);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (Array.isArray(json.ownAccounts.order)) {
|
|
977
|
+
parsedInstance.ownAccountsOrder = [...json.ownAccounts.order];
|
|
978
|
+
}
|
|
979
|
+
// Fallback: if accounts array was empty but order exists, try to populate
|
|
980
|
+
// ownAccounts from the restored contactDirectory entries
|
|
981
|
+
try {
|
|
982
|
+
if (Array.isArray(json.ownAccounts.order) &&
|
|
983
|
+
parsedInstance.ownAccounts.size === 0) {
|
|
984
|
+
for (const id of json.ownAccounts.order) {
|
|
985
|
+
try {
|
|
986
|
+
const c = parsedInstance.contactDirectory.getContact(id);
|
|
987
|
+
if (c)
|
|
988
|
+
parsedInstance.ownAccounts.set(id, c);
|
|
989
|
+
}
|
|
990
|
+
catch (e) {
|
|
991
|
+
// ignore missing contacts
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
// ignore
|
|
998
|
+
}
|
|
999
|
+
// Also add own accounts into contact directory for discoverability
|
|
1000
|
+
try {
|
|
1001
|
+
parsedInstance.ownAccountsOrder.forEach((id) => {
|
|
1002
|
+
const c = parsedInstance.ownAccounts.get(id);
|
|
1003
|
+
if (c && !parsedInstance.contactDirectory.hasContact(c.id)) {
|
|
1004
|
+
parsedInstance.contactDirectory.addContact(c);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
// ignore
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch (e) {
|
|
1014
|
+
console.warn("Error restoring ownAccounts:", e);
|
|
1015
|
+
}
|
|
1016
|
+
// restore pin hash if present
|
|
1017
|
+
try {
|
|
1018
|
+
const anyJson = json;
|
|
1019
|
+
if (anyJson.pinHash)
|
|
1020
|
+
parsedInstance.pinHash = anyJson.pinHash;
|
|
1021
|
+
}
|
|
1022
|
+
catch (e) {
|
|
1023
|
+
// ignore
|
|
1024
|
+
}
|
|
1025
|
+
return parsedInstance;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Set a PIN (stores hash). Passphrase is any string; we store SHA-256(base64) of it.
|
|
1029
|
+
*/
|
|
1030
|
+
async setPIN(pin) {
|
|
1031
|
+
if (!pin)
|
|
1032
|
+
throw new Error("PIN must be a non-empty string");
|
|
1033
|
+
const hash = await MajikMessage.hashPIN(pin);
|
|
1034
|
+
this.pinHash = hash;
|
|
1035
|
+
this.scheduleAutosave();
|
|
1036
|
+
}
|
|
1037
|
+
async clearPIN() {
|
|
1038
|
+
this.pinHash = null;
|
|
1039
|
+
this.scheduleAutosave();
|
|
1040
|
+
}
|
|
1041
|
+
async isValidPIN(pin) {
|
|
1042
|
+
if (!this.pinHash)
|
|
1043
|
+
return true; // no PIN set => always valid
|
|
1044
|
+
const hash = await MajikMessage.hashPIN(pin);
|
|
1045
|
+
return hash === this.pinHash;
|
|
1046
|
+
}
|
|
1047
|
+
getPinHash() {
|
|
1048
|
+
return this.pinHash || null;
|
|
1049
|
+
}
|
|
1050
|
+
static async hashPIN(pin) {
|
|
1051
|
+
const data = new TextEncoder().encode(pin);
|
|
1052
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
1053
|
+
// base64 encode
|
|
1054
|
+
const b64 = arrayBufferToBase64(digest);
|
|
1055
|
+
return b64;
|
|
1056
|
+
}
|
|
1057
|
+
/* ================================
|
|
1058
|
+
* Persistence
|
|
1059
|
+
* ================================ */
|
|
1060
|
+
autosaveIntervalId = null;
|
|
1061
|
+
attachAutosaveHandlers() {
|
|
1062
|
+
if (typeof window !== "undefined") {
|
|
1063
|
+
// Save before unload (best-effort)
|
|
1064
|
+
try {
|
|
1065
|
+
window.addEventListener("beforeunload", () => {
|
|
1066
|
+
void this.saveState();
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
catch (e) {
|
|
1070
|
+
// ignore
|
|
1071
|
+
}
|
|
1072
|
+
// Start periodic backups
|
|
1073
|
+
this.startAutosave();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
startAutosave() {
|
|
1077
|
+
if (this.autosaveIntervalId)
|
|
1078
|
+
return;
|
|
1079
|
+
if (typeof window === "undefined")
|
|
1080
|
+
return;
|
|
1081
|
+
this.autosaveIntervalId = window.setInterval(() => {
|
|
1082
|
+
void this.saveState();
|
|
1083
|
+
}, this.autosaveIntervalMs);
|
|
1084
|
+
}
|
|
1085
|
+
stopAutosave() {
|
|
1086
|
+
if (!this.autosaveIntervalId)
|
|
1087
|
+
return;
|
|
1088
|
+
if (typeof window !== "undefined") {
|
|
1089
|
+
window.clearInterval(this.autosaveIntervalId);
|
|
1090
|
+
}
|
|
1091
|
+
this.autosaveIntervalId = null;
|
|
1092
|
+
}
|
|
1093
|
+
scheduleAutosave() {
|
|
1094
|
+
try {
|
|
1095
|
+
if (this.autosaveTimer) {
|
|
1096
|
+
if (typeof window !== "undefined")
|
|
1097
|
+
window.clearTimeout(this.autosaveTimer);
|
|
1098
|
+
this.autosaveTimer = null;
|
|
1099
|
+
}
|
|
1100
|
+
if (typeof window !== "undefined") {
|
|
1101
|
+
this.autosaveTimer = window.setTimeout(() => {
|
|
1102
|
+
void this.saveState();
|
|
1103
|
+
this.autosaveTimer = null;
|
|
1104
|
+
}, this.autosaveDebounceMs);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch (e) {
|
|
1108
|
+
// ignore scheduling errors
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/** Save current state into IndexedDB (autosave). */
|
|
1112
|
+
async saveState() {
|
|
1113
|
+
try {
|
|
1114
|
+
const jsonDocument = await this.toJSON();
|
|
1115
|
+
const autosaveBlob = autoSaveMajikFileData(jsonDocument);
|
|
1116
|
+
await idbSaveBlob("majik-message-state", autosaveBlob, this.userProfile);
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
console.error("Failed to save MajikMessage state:", err);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/** Load state from IndexedDB and apply to this instance. */
|
|
1123
|
+
async loadState() {
|
|
1124
|
+
try {
|
|
1125
|
+
const autosaveData = await idbLoadBlob("majik-message-state", this.userProfile);
|
|
1126
|
+
if (!autosaveData?.data)
|
|
1127
|
+
return;
|
|
1128
|
+
const blobFile = autosaveData.data;
|
|
1129
|
+
const loadedData = await loadSavedMajikFileData(blobFile);
|
|
1130
|
+
const parsedJSON = loadedData.j;
|
|
1131
|
+
// Use fromJSON to ensure ownAccounts and other fields are restored consistently
|
|
1132
|
+
const restored = await MajikMessage.fromJSON(parsedJSON);
|
|
1133
|
+
this.id = restored.id;
|
|
1134
|
+
this.contactDirectory = restored.contactDirectory;
|
|
1135
|
+
this.envelopeCache = restored.envelopeCache;
|
|
1136
|
+
this.ownAccounts = restored.ownAccounts;
|
|
1137
|
+
this.ownAccountsOrder = [...restored.ownAccountsOrder];
|
|
1138
|
+
}
|
|
1139
|
+
catch (err) {
|
|
1140
|
+
console.error("Failed to load MajikMessage state:", err);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Try to load an existing state from IDB; if none exists, create a fresh instance and save it.
|
|
1145
|
+
*/
|
|
1146
|
+
static async loadOrCreate(config, userProfile = "default") {
|
|
1147
|
+
try {
|
|
1148
|
+
const saved = await idbLoadBlob("majik-message-state", userProfile);
|
|
1149
|
+
if (saved?.data) {
|
|
1150
|
+
const loaded = await loadSavedMajikFileData(saved.data);
|
|
1151
|
+
const parsedJSON = loaded.j;
|
|
1152
|
+
const instance = (await this.fromJSON(parsedJSON));
|
|
1153
|
+
console.log("Account Loaded Successfully");
|
|
1154
|
+
instance.attachAutosaveHandlers();
|
|
1155
|
+
return instance;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
console.warn("Error trying to load saved MajikMessage state:", err);
|
|
1160
|
+
}
|
|
1161
|
+
// No saved state → create new subclass instance
|
|
1162
|
+
const created = new this(config);
|
|
1163
|
+
await created.saveState();
|
|
1164
|
+
created.attachAutosaveHandlers();
|
|
1165
|
+
return created;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Reset all data to a fresh state.
|
|
1169
|
+
* Clears cache, own accounts, contact directory, keystore, and saved data.
|
|
1170
|
+
* WARNING: This operation is irreversible and will delete all user data.
|
|
1171
|
+
*/
|
|
1172
|
+
async resetData(userProfile = "default") {
|
|
1173
|
+
try {
|
|
1174
|
+
// 1. Clear envelope cache
|
|
1175
|
+
await this.clearCachedEnvelopes();
|
|
1176
|
+
// 2. Clear all own accounts from keystore
|
|
1177
|
+
const accountIds = [...this.ownAccountsOrder];
|
|
1178
|
+
for (const id of accountIds) {
|
|
1179
|
+
try {
|
|
1180
|
+
// Delete from KeyStore storage
|
|
1181
|
+
await KeyStore.deleteIdentity?.(id).catch(() => { });
|
|
1182
|
+
}
|
|
1183
|
+
catch (e) {
|
|
1184
|
+
console.warn(`Failed to delete identity ${id} from KeyStore:`, e);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
// 3. Clear own accounts map and order
|
|
1188
|
+
this.ownAccounts.clear();
|
|
1189
|
+
this.ownAccountsOrder = [];
|
|
1190
|
+
// 4. Clear contact directory
|
|
1191
|
+
try {
|
|
1192
|
+
this.contactDirectory.clear();
|
|
1193
|
+
}
|
|
1194
|
+
catch (e) {
|
|
1195
|
+
console.warn(`Failed to clear contacts directory: `, e);
|
|
1196
|
+
}
|
|
1197
|
+
// 5. Clear PIN hash
|
|
1198
|
+
this.pinHash = null;
|
|
1199
|
+
// 6. Reset unlocked state
|
|
1200
|
+
this.unlocked = false;
|
|
1201
|
+
// 7. Clear saved state from IndexedDB
|
|
1202
|
+
try {
|
|
1203
|
+
await clearAllBlobs(userProfile);
|
|
1204
|
+
}
|
|
1205
|
+
catch (e) {
|
|
1206
|
+
console.warn("Failed to clear saved state from IndexedDB:", e);
|
|
1207
|
+
}
|
|
1208
|
+
// 8. Generate new ID for fresh instance
|
|
1209
|
+
this.id = arrayToBase64(randomBytes(32));
|
|
1210
|
+
// 9. Stop and restart autosave to ensure clean state
|
|
1211
|
+
this.stopAutosave();
|
|
1212
|
+
this.startAutosave();
|
|
1213
|
+
this.emit("active-account-change", null);
|
|
1214
|
+
console.log("MajikMessage data reset successfully");
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
console.error("Error during resetData:", err);
|
|
1218
|
+
throw new Error(`Failed to reset data: ${err instanceof Error ? err.message : err}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|