@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,575 @@
1
+ import { arrayBufferToBase64, base64ToArrayBuffer, base64ToUtf8, utf8ToBase64, concatUint8Arrays, arrayToBase64, } from "../utils/utilities";
2
+ import { KEY_ALGO, MAJIK_SALT } from "./constants";
3
+ import { EncryptionEngine } from "./encryption-engine";
4
+ import { generateMnemonic } from "@scure/bip39";
5
+ import { wordlist } from "@scure/bip39/wordlists/english";
6
+ import { generateRandomBytes, deriveKeyFromPassphrase as providerDeriveKeyFromPassphrase, deriveKeyFromMnemonic as providerDeriveKeyFromMnemonic, aesGcmEncrypt, aesGcmDecrypt, IV_LENGTH, } from "./crypto-provider";
7
+ /* -------------------------------
8
+ * Errors
9
+ * ------------------------------- */
10
+ export class KeyStoreError extends Error {
11
+ cause;
12
+ constructor(message, cause) {
13
+ super(message);
14
+ this.name = "KeyStoreError";
15
+ this.cause = cause;
16
+ }
17
+ }
18
+ /* -------------------------------
19
+ * KeyStore Class
20
+ * ------------------------------- */
21
+ /**
22
+ * KeyStore
23
+ * ----------------
24
+ * Secure storage for MajikMessage identities.
25
+ * Stores public keys and fingerprints in plaintext,
26
+ * encrypts private keys with a passphrase.
27
+ *
28
+ * ⚠️ Background-only. Never expose private keys to content scripts.
29
+ */
30
+ export class KeyStore {
31
+ static DB_NAME = "MajikMessageKeyStore";
32
+ static STORE_NAME = "identities";
33
+ static DB_VERSION = 1;
34
+ static dbPromise = null;
35
+ // In-memory unlocked identities (id -> identity)
36
+ static unlockedIdentities = new Map();
37
+ // Optional callback: invoked when UI needs to request a passphrase to unlock an identity.
38
+ // Should return the passphrase string or a Promise<string>.
39
+ static onUnlockRequested;
40
+ /* ================================
41
+ * Chrome Storage Helpers
42
+ * ================================ */
43
+ static async computeChecksum(identities) {
44
+ const json = JSON.stringify(identities);
45
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(json));
46
+ return arrayBufferToBase64(hash);
47
+ }
48
+ /* ================================
49
+ * IndexedDB Helpers
50
+ * ================================ */
51
+ static async getDB() {
52
+ if (this.dbPromise)
53
+ return this.dbPromise;
54
+ this.dbPromise = new Promise((resolve, reject) => {
55
+ const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
56
+ request.onupgradeneeded = () => {
57
+ const db = request.result;
58
+ if (!db.objectStoreNames.contains(this.STORE_NAME)) {
59
+ db.createObjectStore(this.STORE_NAME, { keyPath: "id" });
60
+ }
61
+ };
62
+ request.onsuccess = () => resolve(request.result);
63
+ request.onerror = () => reject(new KeyStoreError("IndexedDB open failed", request.error));
64
+ });
65
+ return this.dbPromise;
66
+ }
67
+ static async putSerializedIdentity(identity) {
68
+ const db = await this.getDB();
69
+ return new Promise((resolve, reject) => {
70
+ const tx = db.transaction(this.STORE_NAME, "readwrite");
71
+ const store = tx.objectStore(this.STORE_NAME);
72
+ const req = store.put(identity);
73
+ req.onsuccess = () => resolve();
74
+ req.onerror = () => reject(new KeyStoreError("Failed to store identity", req.error));
75
+ });
76
+ }
77
+ static async getSerializedIdentity(id) {
78
+ const db = await this.getDB();
79
+ return new Promise((resolve, reject) => {
80
+ const tx = db.transaction(this.STORE_NAME, "readonly");
81
+ const store = tx.objectStore(this.STORE_NAME);
82
+ const req = store.get(id);
83
+ req.onsuccess = () => resolve(req.result || null);
84
+ req.onerror = () => reject(new KeyStoreError("Failed to retrieve identity", req.error));
85
+ });
86
+ }
87
+ /* ================================
88
+ * Public API
89
+ * ================================ */
90
+ /**
91
+ * Validates whether a passphrase can decrypt the stored private key.
92
+ * Does NOT unlock or mutate any in-memory state.
93
+ */
94
+ static async isPassphraseValid(id, passphrase) {
95
+ if (!passphrase)
96
+ return false;
97
+ try {
98
+ const serialized = await this.getSerializedIdentity(id);
99
+ if (!serialized || !serialized.encryptedPrivateKey)
100
+ return false;
101
+ const encrypted = base64ToArrayBuffer(serialized.encryptedPrivateKey);
102
+ const salt = serialized.salt
103
+ ? new Uint8Array(base64ToArrayBuffer(serialized.salt))
104
+ : new TextEncoder().encode(MAJIK_SALT);
105
+ // Attempt authenticated decryption
106
+ await this.decryptPrivateKey(encrypted, passphrase, salt);
107
+ // If no error → passphrase is valid
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ static async updatePassphrase(id, currentPassphrase, newPassphrase) {
115
+ if (!newPassphrase || typeof newPassphrase !== "string") {
116
+ throw new KeyStoreError("New passphrase must be a non-empty string");
117
+ }
118
+ const serialized = await this.getSerializedIdentity(id);
119
+ if (!serialized || !serialized.encryptedPrivateKey) {
120
+ throw new KeyStoreError("Identity not found or has no private key");
121
+ }
122
+ // ✅ Validate current passphrase
123
+ const valid = await this.isPassphraseValid(id, currentPassphrase);
124
+ if (!valid) {
125
+ throw new KeyStoreError("Current passphrase is incorrect");
126
+ }
127
+ // 1. Decrypt with current passphrase (we already know it's valid)
128
+ const encrypted = base64ToArrayBuffer(serialized.encryptedPrivateKey);
129
+ const salt = serialized.salt
130
+ ? new Uint8Array(base64ToArrayBuffer(serialized.salt))
131
+ : new TextEncoder().encode(MAJIK_SALT);
132
+ const privateKeyBuffer = await this.decryptPrivateKey(encrypted, currentPassphrase, salt);
133
+ // 2. Re-encrypt with new passphrase + new salt
134
+ const newSalt = generateRandomBytes(16);
135
+ const newEncryptedPrivateKey = await this.encryptPrivateKey(privateKeyBuffer, newPassphrase, newSalt);
136
+ // 3. Persist updated identity
137
+ const updated = {
138
+ ...serialized,
139
+ encryptedPrivateKey: arrayBufferToBase64(newEncryptedPrivateKey),
140
+ salt: arrayToBase64(newSalt),
141
+ };
142
+ await this.putSerializedIdentity(updated);
143
+ // 4. Update in-memory unlocked identity (if present)
144
+ const unlocked = this.unlockedIdentities.get(id);
145
+ if (unlocked) {
146
+ unlocked.encryptedPrivateKey = newEncryptedPrivateKey;
147
+ }
148
+ }
149
+ /**
150
+ * Check if an identity exists in memory or in storage
151
+ */
152
+ static async hasIdentity(fingerprint) {
153
+ // First, check in-memory unlocked identities
154
+ for (const ident of this.unlockedIdentities.values()) {
155
+ if (ident.fingerprint === fingerprint)
156
+ return true;
157
+ }
158
+ // Then, check IndexedDB (persistent storage)
159
+ const allStored = await this.listStoredIdentities();
160
+ return allStored.some((i) => i.fingerprint === fingerprint);
161
+ }
162
+ /**
163
+ * Creates a new identity and stores it securely.
164
+ */
165
+ static async createIdentity(passphrase) {
166
+ if (!passphrase || typeof passphrase !== "string") {
167
+ throw new KeyStoreError("Passphrase must be a non-empty string");
168
+ }
169
+ try {
170
+ const identity = await EncryptionEngine.generateIdentity();
171
+ const id = crypto.randomUUID();
172
+ let exportedPrivateKey;
173
+ try {
174
+ exportedPrivateKey = await crypto.subtle.exportKey("raw", identity.privateKey);
175
+ }
176
+ catch (e) {
177
+ const anyPriv = identity.privateKey;
178
+ if (anyPriv && anyPriv.raw instanceof Uint8Array) {
179
+ exportedPrivateKey = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
180
+ }
181
+ else {
182
+ throw e;
183
+ }
184
+ }
185
+ const salt = generateRandomBytes(16);
186
+ const encryptedPrivateKey = await this.encryptPrivateKey(exportedPrivateKey, passphrase, salt);
187
+ const serialized = {
188
+ id,
189
+ publicKey: await this.exportPublicKeyBase64(identity.publicKey),
190
+ fingerprint: identity.fingerprint,
191
+ encryptedPrivateKey: arrayBufferToBase64(encryptedPrivateKey),
192
+ salt: arrayToBase64(salt),
193
+ };
194
+ await this.putSerializedIdentity(serialized);
195
+ const ksIdentity = {
196
+ id,
197
+ publicKey: identity.publicKey,
198
+ fingerprint: identity.fingerprint,
199
+ encryptedPrivateKey,
200
+ unlocked: true,
201
+ privateKey: identity.privateKey,
202
+ };
203
+ // Cache unlocked identity in-memory for quick access
204
+ this.unlockedIdentities.set(id, ksIdentity);
205
+ return ksIdentity;
206
+ }
207
+ catch (err) {
208
+ throw new KeyStoreError("Failed to create identity", err);
209
+ }
210
+ }
211
+ /**
212
+ * Create a deterministic identity from a mnemonic and store it encrypted with passphrase.
213
+ * The identity `id` is set to the fingerprint for stable referencing.
214
+ */
215
+ static async createIdentityFromMnemonic(mnemonic, passphrase) {
216
+ if (!mnemonic || typeof mnemonic !== "string") {
217
+ throw new KeyStoreError("Mnemonic must be a non-empty string");
218
+ }
219
+ try {
220
+ const identity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
221
+ const id = identity.fingerprint; // stable id
222
+ let exportedPrivate;
223
+ try {
224
+ exportedPrivate = await crypto.subtle.exportKey("raw", identity.privateKey);
225
+ }
226
+ catch (e) {
227
+ const anyPriv = identity.privateKey;
228
+ if (anyPriv && anyPriv.raw instanceof Uint8Array) {
229
+ exportedPrivate = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
230
+ }
231
+ else {
232
+ throw e;
233
+ }
234
+ }
235
+ const salt = generateRandomBytes(16);
236
+ const encryptedPrivateKey = await this.encryptPrivateKey(exportedPrivate, passphrase, salt);
237
+ const serialized = {
238
+ id,
239
+ publicKey: await this.exportPublicKeyBase64(identity.publicKey),
240
+ fingerprint: identity.fingerprint,
241
+ encryptedPrivateKey: arrayBufferToBase64(encryptedPrivateKey),
242
+ salt: arrayToBase64(salt),
243
+ };
244
+ await this.putSerializedIdentity(serialized);
245
+ const ksIdentity = {
246
+ id,
247
+ publicKey: identity.publicKey,
248
+ fingerprint: identity.fingerprint,
249
+ encryptedPrivateKey,
250
+ unlocked: true,
251
+ privateKey: identity.privateKey,
252
+ };
253
+ this.unlockedIdentities.set(id, ksIdentity);
254
+ return ksIdentity;
255
+ }
256
+ catch (err) {
257
+ throw new KeyStoreError("Failed to create identity from mnemonic", err);
258
+ }
259
+ }
260
+ /**
261
+ * Unlocks a stored identity with the passphrase.
262
+ */
263
+ static async unlockIdentity(id, passphrase) {
264
+ const serialized = await this.getSerializedIdentity(id);
265
+ if (!serialized)
266
+ throw new KeyStoreError("Identity not found");
267
+ if (!serialized.encryptedPrivateKey)
268
+ throw new KeyStoreError("No private key stored");
269
+ const encrypted = base64ToArrayBuffer(serialized.encryptedPrivateKey);
270
+ // Use stored per-identity salt if present, otherwise fall back to global salt
271
+ const salt = serialized.salt
272
+ ? new Uint8Array(base64ToArrayBuffer(serialized.salt))
273
+ : new TextEncoder().encode(MAJIK_SALT);
274
+ const privateKeyBuffer = await this.decryptPrivateKey(encrypted, passphrase, salt);
275
+ let privateKey;
276
+ try {
277
+ privateKey = await crypto.subtle.importKey("raw", privateKeyBuffer, KEY_ALGO, true, ["deriveKey", "deriveBits"]);
278
+ }
279
+ catch (e) {
280
+ // WebCrypto doesn't support importing X25519; keep raw wrapper
281
+ privateKey = {
282
+ type: "private",
283
+ raw: new Uint8Array(privateKeyBuffer),
284
+ };
285
+ }
286
+ const publicKey = await this.importPublicKeyBase64(serialized.publicKey);
287
+ const ksIdentity = {
288
+ id,
289
+ publicKey,
290
+ fingerprint: serialized.fingerprint,
291
+ encryptedPrivateKey: encrypted,
292
+ unlocked: true,
293
+ privateKey: privateKey,
294
+ };
295
+ // Cache unlocked identity
296
+ this.unlockedIdentities.set(id, ksIdentity);
297
+ return ksIdentity;
298
+ }
299
+ /**
300
+ * Get the private key of an unlocked identity by ID or fingerprint.
301
+ * Throws if the identity is not found or not unlocked.
302
+ */
303
+ static async getPrivateKey(idOrFingerprint) {
304
+ // First, check in-memory unlocked identities by id
305
+ const byId = this.unlockedIdentities.get(idOrFingerprint);
306
+ if (byId && byId.privateKey)
307
+ return byId.privateKey;
308
+ // Then check unlocked identities by fingerprint
309
+ for (const ident of this.unlockedIdentities.values()) {
310
+ if (ident.fingerprint === idOrFingerprint && ident.privateKey)
311
+ return ident.privateKey;
312
+ }
313
+ // Not unlocked in memory -- instruct caller to unlock first
314
+ throw new KeyStoreError(`Identity with ID/fingerprint "${idOrFingerprint}" must be unlocked first via unlockIdentity()`);
315
+ }
316
+ /**
317
+ * Locks a stored identity (removes private key from memory).
318
+ */
319
+ static async lockIdentity(identity) {
320
+ // Remove from in-memory cache
321
+ if (identity && identity.id)
322
+ this.unlockedIdentities.delete(identity.id);
323
+ return { ...identity, unlocked: false, privateKey: undefined };
324
+ }
325
+ /**
326
+ * Gets a stored identity's public key.
327
+ */
328
+ static async getPublicKey(id) {
329
+ const serialized = await this.getSerializedIdentity(id);
330
+ if (!serialized)
331
+ throw new KeyStoreError("Identity not found");
332
+ return this.importPublicKeyBase64(serialized.publicKey);
333
+ }
334
+ /**
335
+ * Gets a stored identity's fingerprint.
336
+ */
337
+ static async getFingerprint(id) {
338
+ const serialized = await this.getSerializedIdentity(id);
339
+ if (!serialized)
340
+ throw new KeyStoreError("Identity not found");
341
+ return serialized.fingerprint;
342
+ }
343
+ /* ================================
344
+ * Private Helpers
345
+ * ================================ */
346
+ static async encryptPrivateKey(buffer, passphrase, salt) {
347
+ const keyBytes = providerDeriveKeyFromPassphrase(passphrase, salt);
348
+ const iv = generateRandomBytes(IV_LENGTH);
349
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(buffer));
350
+ return concatUint8Arrays(iv, ciphertext).buffer;
351
+ }
352
+ static async decryptPrivateKey(buffer, passphrase, salt) {
353
+ const keyBytes = providerDeriveKeyFromPassphrase(passphrase, salt);
354
+ const full = new Uint8Array(buffer);
355
+ const iv = full.slice(0, IV_LENGTH);
356
+ const ciphertext = full.slice(IV_LENGTH);
357
+ const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
358
+ if (!plain)
359
+ throw new KeyStoreError("Failed to decrypt private key (auth failed)");
360
+ return plain.buffer;
361
+ }
362
+ static async exportPublicKeyBase64(key) {
363
+ const anyKey = key;
364
+ if (anyKey && anyKey.raw instanceof Uint8Array) {
365
+ return arrayBufferToBase64(anyKey.raw.buffer);
366
+ }
367
+ const raw = await crypto.subtle.exportKey("raw", key);
368
+ return arrayBufferToBase64(raw);
369
+ }
370
+ static async importPublicKeyBase64(base64) {
371
+ const raw = base64ToArrayBuffer(base64);
372
+ try {
373
+ return await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
374
+ }
375
+ catch (e) {
376
+ // WebCrypto may not support X25519; return a raw-key wrapper as fallback
377
+ const ua = new Uint8Array(raw);
378
+ const wrapper = { type: "public", raw: ua };
379
+ return wrapper;
380
+ }
381
+ }
382
+ /**
383
+ * Export a stored identity as a compact base64 backup blob (JSON -> base64).
384
+ * The exported blob contains the encrypted private key (already encrypted with user's passphrase)
385
+ * so the caller must preserve it securely. This is not a human-readable mnemonic.
386
+ */
387
+ static async exportIdentityBackup(id) {
388
+ const serialized = await this.getSerializedIdentity(id);
389
+ if (!serialized)
390
+ throw new KeyStoreError("Identity not found");
391
+ const payload = {
392
+ id: serialized.id,
393
+ publicKey: serialized.publicKey,
394
+ fingerprint: serialized.fingerprint,
395
+ encryptedPrivateKey: serialized.encryptedPrivateKey || null,
396
+ };
397
+ return utf8ToBase64(JSON.stringify(payload));
398
+ }
399
+ /**
400
+ * Import an identity backup previously exported via `exportIdentityBackup`.
401
+ * This stores the serialized identity in IndexedDB. Caller can then call `unlockIdentity`.
402
+ */
403
+ static async importIdentityBackup(backupBase64) {
404
+ try {
405
+ const jsonStr = base64ToUtf8(backupBase64);
406
+ const obj = JSON.parse(jsonStr);
407
+ if (!obj.id || !obj.publicKey || !obj.fingerprint) {
408
+ throw new KeyStoreError("Malformed backup blob");
409
+ }
410
+ await this.putSerializedIdentity(obj);
411
+ }
412
+ catch (err) {
413
+ throw new KeyStoreError("Failed to import identity backup", err);
414
+ }
415
+ }
416
+ /**
417
+ * List all serialized identities stored in IndexedDB.
418
+ */
419
+ static async listStoredIdentities() {
420
+ const db = await this.getDB();
421
+ return new Promise((resolve, reject) => {
422
+ const tx = db.transaction(this.STORE_NAME, "readonly");
423
+ const store = tx.objectStore(this.STORE_NAME);
424
+ const req = store.getAll();
425
+ req.onsuccess = () => resolve(req.result || []);
426
+ req.onerror = () => reject(new KeyStoreError("Failed to list identities", req.error));
427
+ });
428
+ }
429
+ /**
430
+ * Delete an identity by id from storage and in-memory caches.
431
+ */
432
+ static async deleteIdentity(id) {
433
+ const db = await this.getDB();
434
+ return new Promise((resolve, reject) => {
435
+ const tx = db.transaction(this.STORE_NAME, "readwrite");
436
+ const store = tx.objectStore(this.STORE_NAME);
437
+ store.delete(id);
438
+ tx.oncomplete = async () => {
439
+ try {
440
+ // In-memory cleanup
441
+ this.unlockedIdentities.delete(id);
442
+ resolve();
443
+ }
444
+ catch (err) {
445
+ reject(err);
446
+ }
447
+ };
448
+ tx.onerror = () => reject(new KeyStoreError("Failed to delete identity", tx.error));
449
+ });
450
+ }
451
+ /**
452
+ * Generate a BIP39 mnemonic (default 12 words / 128 bits entropy).
453
+ */
454
+ static generateMnemonic(strength = 128) {
455
+ // @scure/bip39's generateMnemonic accepts entropy bits (128 -> 12 words)
456
+ // Call with strength only; use default English wordlist
457
+ return generateMnemonic(wordlist, strength);
458
+ }
459
+ /**
460
+ * Export an identity encrypted with a mnemonic-derived key.
461
+ * Requires the identity to be unlocked in memory (privateKey available).
462
+ * Returns a base64 string containing iv+ciphertext and publicKey/fingerprint in JSON.
463
+ */
464
+ static async exportIdentityMnemonicBackup(id, mnemonic) {
465
+ const unlocked = this.unlockedIdentities.get(id);
466
+ if (!unlocked || !unlocked.privateKey) {
467
+ throw new KeyStoreError("Identity must be unlocked before exporting mnemonic backup");
468
+ }
469
+ // Export private key (raw) and public key (raw)
470
+ // Export private key (raw) and public key (raw)
471
+ let privRawBuf;
472
+ let pubRawBuf;
473
+ try {
474
+ privRawBuf = await crypto.subtle.exportKey("raw", unlocked.privateKey);
475
+ pubRawBuf = await crypto.subtle.exportKey("raw", unlocked.publicKey);
476
+ }
477
+ catch (e) {
478
+ const anyPriv = unlocked.privateKey;
479
+ const anyPub = unlocked.publicKey;
480
+ if (anyPriv && anyPriv.raw instanceof Uint8Array) {
481
+ privRawBuf = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
482
+ }
483
+ else {
484
+ throw e;
485
+ }
486
+ if (anyPub && anyPub.raw instanceof Uint8Array) {
487
+ pubRawBuf = anyPub.raw.buffer.slice(anyPub.raw.byteOffset, anyPub.raw.byteOffset + anyPub.raw.byteLength);
488
+ }
489
+ else {
490
+ throw e;
491
+ }
492
+ }
493
+ // Derive AES key from mnemonic using Stablelib provider
494
+ const salt = new TextEncoder().encode("MajikMessageMnemonicSalt");
495
+ const keyBytes = providerDeriveKeyFromMnemonic(mnemonic, salt);
496
+ const iv = generateRandomBytes(IV_LENGTH);
497
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(privRawBuf));
498
+ const packaged = {
499
+ id: unlocked.id,
500
+ iv: arrayToBase64(iv),
501
+ ciphertext: arrayToBase64(ciphertext),
502
+ publicKey: arrayBufferToBase64(pubRawBuf),
503
+ fingerprint: unlocked.fingerprint,
504
+ };
505
+ return utf8ToBase64(JSON.stringify(packaged));
506
+ }
507
+ /**
508
+ * Import an identity from a mnemonic-encrypted backup blob and store it encrypted with `passphrase`.
509
+ */
510
+ static async importIdentityFromMnemonicBackup(backupBase64, mnemonic, passphrase) {
511
+ try {
512
+ const jsonStr = base64ToUtf8(backupBase64);
513
+ const obj = JSON.parse(jsonStr);
514
+ if (!obj.iv || !obj.ciphertext || !obj.publicKey || !obj.fingerprint) {
515
+ throw new KeyStoreError("Malformed mnemonic backup");
516
+ }
517
+ const fullKey = await this.deriveKeyFromMnemonic(mnemonic);
518
+ const iv = new Uint8Array(base64ToArrayBuffer(obj.iv));
519
+ const ciphertext = base64ToArrayBuffer(obj.ciphertext);
520
+ const raw = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, fullKey, ciphertext);
521
+ let privateKey;
522
+ try {
523
+ privateKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, [
524
+ "deriveKey",
525
+ "deriveBits",
526
+ ]);
527
+ }
528
+ catch (e) {
529
+ // WebCrypto does not support X25519 – store raw key
530
+ privateKey = {
531
+ type: "private",
532
+ raw: new Uint8Array(raw),
533
+ };
534
+ }
535
+ const publicKey = await this.importPublicKeyBase64(obj.publicKey);
536
+ // Now encrypt private key with user passphrase for storage and put into IndexedDB
537
+ const exportedPrivateKey = raw;
538
+ const salt = generateRandomBytes(16);
539
+ const encryptedPrivateKey = await this.encryptPrivateKey(exportedPrivateKey, passphrase, salt);
540
+ const id = obj.id || crypto.randomUUID();
541
+ const serialized = {
542
+ id,
543
+ publicKey: obj.publicKey,
544
+ fingerprint: obj.fingerprint,
545
+ encryptedPrivateKey: arrayBufferToBase64(encryptedPrivateKey),
546
+ salt: arrayToBase64(salt),
547
+ };
548
+ await this.putSerializedIdentity(serialized);
549
+ const ksIdentity = {
550
+ id: serialized.id,
551
+ publicKey,
552
+ fingerprint: serialized.fingerprint,
553
+ encryptedPrivateKey: encryptedPrivateKey,
554
+ unlocked: true,
555
+ privateKey,
556
+ };
557
+ // Cache unlocked identity
558
+ this.unlockedIdentities.set(ksIdentity.id, ksIdentity);
559
+ return ksIdentity;
560
+ }
561
+ catch (err) {
562
+ throw new KeyStoreError("Failed to import mnemonic backup", err);
563
+ }
564
+ }
565
+ static async deriveKeyFromMnemonic(mnemonic) {
566
+ const salt = new TextEncoder().encode("MajikMessageMnemonicSalt");
567
+ const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(mnemonic), { name: "PBKDF2" }, false, ["deriveKey"]);
568
+ return crypto.subtle.deriveKey({
569
+ name: "PBKDF2",
570
+ salt,
571
+ iterations: 200_000,
572
+ hash: "SHA-256",
573
+ }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
574
+ }
575
+ }
@@ -0,0 +1,51 @@
1
+ import type { MAJIK_API_RESPONSE } from "../types";
2
+ import { MessageEnvelope } from "./message-envelope";
3
+ export declare class EnvelopeCacheError extends Error {
4
+ cause?: unknown;
5
+ constructor(message: string, cause?: unknown);
6
+ }
7
+ export interface EnvelopeCacheConfig {
8
+ dbName?: string;
9
+ storeName?: string;
10
+ maxEntries?: number;
11
+ memoryCacheSize?: number;
12
+ }
13
+ export interface EnvelopeCacheJSON {
14
+ config: EnvelopeCacheConfig;
15
+ items: Array<EnvelopeCacheItemJSON>;
16
+ }
17
+ export interface EnvelopeCacheItemJSON {
18
+ id: string;
19
+ base64Payload: string;
20
+ timestamp: number;
21
+ source?: string;
22
+ }
23
+ export interface EnvelopeCacheItem {
24
+ id: string;
25
+ envelope: MessageEnvelope;
26
+ timestamp: number;
27
+ source?: string;
28
+ message?: string;
29
+ }
30
+ export declare class EnvelopeCache {
31
+ private dbPromise;
32
+ private dbName;
33
+ private storeName;
34
+ private maxEntries?;
35
+ private memoryCache;
36
+ private memoryCacheSize;
37
+ constructor(config?: EnvelopeCacheConfig);
38
+ private initDB;
39
+ private getEnvelopeId;
40
+ set(envelope: MessageEnvelope, source?: string): Promise<void>;
41
+ listRecent(offset?: number, limit?: number): Promise<Array<EnvelopeCacheItem>>;
42
+ get(envelope: MessageEnvelope): Promise<MessageEnvelope | undefined>;
43
+ getById(id: string): Promise<MessageEnvelope | undefined>;
44
+ has(envelope: MessageEnvelope): Promise<boolean>;
45
+ delete(envelope: MessageEnvelope): Promise<void>;
46
+ deleteByFingerprint(fingerprint: string): Promise<void>;
47
+ clear(): Promise<MAJIK_API_RESPONSE>;
48
+ private enforceMaxEntries;
49
+ toJSON(): EnvelopeCacheJSON;
50
+ static fromJSON(json: EnvelopeCacheJSON): EnvelopeCache;
51
+ }