@majikah/majik-key 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.
@@ -0,0 +1,763 @@
1
+ import { generateMnemonic } from "@scure/bip39";
2
+ import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromMnemonic, deriveKeyFromPassphrase, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider";
3
+ import { EncryptionEngine } from "./core/crypto/encryption-engine";
4
+ import { MajikContact } from "./core/majik-contact";
5
+ import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, concatUint8Arrays, utf8ToBase64, base64ToUtf8, seedStringToArray, seedArrayToString, } from "./core/utils";
6
+ import { wordlist } from "@scure/bip39/wordlists/english";
7
+ import { KEY_ALGO, MAJIK_MNEMONIC_SALT, MAJIK_SALT, } from "./core/crypto/constants";
8
+ import { MajikKeyValidator } from "./core/validator";
9
+ import { MajikKeyError } from "./core/error";
10
+ import { MajikMessageIdentity } from "./core/database/system/identity";
11
+ /**
12
+ * MajikKey
13
+ * ----------------
14
+ * A seed phrase account library for creating, managing, and parsing mnemonic-based cryptographic accounts (Majik Keys).
15
+ * Generate deterministic key pairs from BIP39 seed phrases with simple, developer-friendly APIs.
16
+ */
17
+ export class MajikKey {
18
+ // Immutable properties
19
+ _id;
20
+ _publicKey;
21
+ _publicKeyBase64;
22
+ _fingerprint;
23
+ _backup;
24
+ _timestamp;
25
+ // Mutable encrypted state
26
+ _encryptedPrivateKey;
27
+ _encryptedPrivateKeyBase64;
28
+ _salt;
29
+ _label;
30
+ // Unlocked state (optional - only present when unlocked)
31
+ _privateKey;
32
+ _privateKeyBase64;
33
+ /* ================================
34
+ * Constructor
35
+ * ================================ */
36
+ constructor(options) {
37
+ this._id = options.id;
38
+ this._publicKey = options.publicKey;
39
+ this._publicKeyBase64 = options.publicKeyBase64;
40
+ this._fingerprint = options.fingerprint;
41
+ this._encryptedPrivateKey = options.encryptedPrivateKey;
42
+ this._encryptedPrivateKeyBase64 = options.encryptedPrivateKeyBase64;
43
+ this._salt = options.salt;
44
+ this._backup = options.backup;
45
+ this._label = options.label || "";
46
+ this._timestamp = options.timestamp || new Date();
47
+ // Optional unlocked state
48
+ this._privateKey = options.privateKey;
49
+ this._privateKeyBase64 = options.privateKeyBase64;
50
+ }
51
+ /* ================================
52
+ * Public Getters
53
+ * ================================ */
54
+ get id() {
55
+ return this._id;
56
+ }
57
+ get fingerprint() {
58
+ return this._fingerprint;
59
+ }
60
+ get publicKey() {
61
+ return this._publicKey;
62
+ }
63
+ get publicKeyBase64() {
64
+ return this._publicKeyBase64;
65
+ }
66
+ get label() {
67
+ return this._label;
68
+ }
69
+ get backup() {
70
+ return this._backup;
71
+ }
72
+ get timestamp() {
73
+ return this._timestamp;
74
+ }
75
+ get isLocked() {
76
+ return this._privateKey === undefined;
77
+ }
78
+ get isUnlocked() {
79
+ return this._privateKey !== undefined;
80
+ }
81
+ /**
82
+ * Get safe metadata (no sensitive data)
83
+ */
84
+ get metadata() {
85
+ return {
86
+ id: this.id,
87
+ fingerprint: this.fingerprint,
88
+ label: this.label,
89
+ timestamp: this.timestamp,
90
+ isLocked: this.isLocked,
91
+ };
92
+ }
93
+ /* ================================
94
+ * CRUD Operations
95
+ * ================================ */
96
+ /**
97
+ * CREATE: Generate a new MajikKey from a mnemonic phrase.
98
+ * The key is created in an unlocked state with private keys available.
99
+ *
100
+ * @param mnemonic - BIP39 mnemonic phrase (12-24 words)
101
+ * @param passphrase - Passphrase to encrypt the private key at rest
102
+ * @param label - Optional label for the key
103
+ * @returns A new unlocked MajikKey instance
104
+ */
105
+ static async create(mnemonic, passphrase, label) {
106
+ try {
107
+ // Validate inputs
108
+ MajikKeyValidator.validateMnemonic(mnemonic);
109
+ MajikKeyValidator.validatePassphrase(passphrase);
110
+ MajikKeyValidator.validateLabel(label);
111
+ // Create identity from mnemonic
112
+ const identity = await this.createIdentityFromMnemonic(mnemonic, passphrase);
113
+ // Export keys to base64
114
+ const privateKeyBase64 = await this.exportKeyToBase64(identity.privateKey);
115
+ const publicKeyBase64 = await this.exportKeyToBase64(identity.publicKey);
116
+ // Create backup
117
+ const backup = await this.exportIdentityMnemonicBackup(identity, mnemonic);
118
+ // Create and return unlocked instance
119
+ return new MajikKey({
120
+ id: identity.id,
121
+ publicKey: identity.publicKey,
122
+ publicKeyBase64,
123
+ fingerprint: identity.fingerprint,
124
+ encryptedPrivateKey: identity.encryptedPrivateKey,
125
+ encryptedPrivateKeyBase64: arrayBufferToBase64(identity.encryptedPrivateKey),
126
+ salt: identity.salt,
127
+ backup,
128
+ label: label || "",
129
+ timestamp: new Date(),
130
+ // Unlocked state
131
+ privateKey: identity.privateKey,
132
+ privateKeyBase64,
133
+ });
134
+ }
135
+ catch (err) {
136
+ if (err instanceof MajikKeyError)
137
+ throw err;
138
+ throw new MajikKeyError("Failed to create MajikKey", err);
139
+ }
140
+ }
141
+ /**
142
+ * Export this MajikKey to MnemonicJSON format.
143
+ * This format is useful for storing mnemonic data with an optional passphrase.
144
+ *
145
+ * @param mnemonic - The BIP39 mnemonic phrase
146
+ * @param passphrase - Optional passphrase (encryption password, not BIP39 passphrase)
147
+ * @returns MnemonicJSON object
148
+ */
149
+ toMnemonicJSON(mnemonic, passphrase) {
150
+ if (this.isLocked) {
151
+ throw new MajikKeyError("Cannot export locked MajikKey to MnemonicJSON. Unlock first.");
152
+ }
153
+ try {
154
+ // Validate mnemonic
155
+ MajikKeyValidator.validateMnemonic(mnemonic);
156
+ // Validate passphrase if provided
157
+ if (passphrase !== undefined) {
158
+ MajikKeyValidator.validatePassphrase(passphrase, "Passphrase");
159
+ }
160
+ return {
161
+ id: this._backup, // Use backup as identifier
162
+ seed: seedStringToArray(mnemonic.trim()),
163
+ phrase: passphrase?.trim() || undefined,
164
+ };
165
+ }
166
+ catch (err) {
167
+ if (err instanceof MajikKeyError)
168
+ throw err;
169
+ throw new MajikKeyError("Failed to create MnemonicJSON", err);
170
+ }
171
+ }
172
+ /**
173
+ * Create a MajikKey from MnemonicJSON format.
174
+ *
175
+ * @param mnemonicJson - MnemonicJSON object or string
176
+ * @param passphrase - Passphrase to encrypt the key at rest
177
+ * @param label - Optional label for the key
178
+ * @returns A new unlocked MajikKey instance
179
+ */
180
+ static async fromMnemonicJSON(mnemonicJson, passphrase, label) {
181
+ try {
182
+ // Parse if string
183
+ const parsed = typeof mnemonicJson === "string"
184
+ ? JSON.parse(mnemonicJson)
185
+ : mnemonicJson;
186
+ // Validate structure
187
+ if (!parsed.id || !parsed.seed || !Array.isArray(parsed.seed)) {
188
+ throw new MajikKeyError("Invalid MnemonicJSON: missing id or seed array");
189
+ }
190
+ // Convert seed array to mnemonic string
191
+ const mnemonic = seedArrayToString(parsed.seed);
192
+ // Validate the mnemonic
193
+ MajikKeyValidator.validateMnemonic(mnemonic);
194
+ // Create the MajikKey using the standard create method
195
+ return await this.create(mnemonic, passphrase, label);
196
+ }
197
+ catch (err) {
198
+ if (err instanceof MajikKeyError)
199
+ throw err;
200
+ throw new MajikKeyError("Failed to create MajikKey from MnemonicJSON", err);
201
+ }
202
+ }
203
+ /**
204
+ * READ: Load a MajikKey from JSON (locked state).
205
+ * The key must be unlocked with the unlock() method before accessing private keys.
206
+ *
207
+ * @param json - JSON string or object
208
+ * @returns A locked MajikKey instance
209
+ */
210
+ static fromJSON(json) {
211
+ try {
212
+ const parsed = typeof json === "string" ? JSON.parse(json) : json;
213
+ const validated = MajikKeyValidator.validateJSON(parsed);
214
+ // Convert base64 to CryptoKey/ArrayBuffer
215
+ const publicKeyBuffer = base64ToArrayBuffer(validated.publicKey);
216
+ const encryptedPrivateKeyBuffer = base64ToArrayBuffer(validated.encryptedPrivateKey);
217
+ // Create locked instance (no private key)
218
+ return new MajikKey({
219
+ id: validated.id,
220
+ publicKey: { raw: new Uint8Array(publicKeyBuffer) },
221
+ publicKeyBase64: validated.publicKey,
222
+ fingerprint: validated.fingerprint,
223
+ encryptedPrivateKey: encryptedPrivateKeyBuffer,
224
+ encryptedPrivateKeyBase64: validated.encryptedPrivateKey,
225
+ salt: validated.salt,
226
+ backup: validated.backup,
227
+ label: validated.label || "",
228
+ timestamp: new Date(validated.timestamp),
229
+ // No private key - locked state
230
+ });
231
+ }
232
+ catch (err) {
233
+ if (err instanceof MajikKeyError)
234
+ throw err;
235
+ throw new MajikKeyError("Failed to parse MajikKey from JSON", err);
236
+ }
237
+ }
238
+ /**
239
+ * UPDATE: Change the label of this MajikKey.
240
+ *
241
+ * @param newLabel - New label value
242
+ * @returns This instance for chaining
243
+ */
244
+ updateLabel(newLabel) {
245
+ MajikKeyValidator.validateLabel(newLabel);
246
+ this._label = newLabel || "";
247
+ return this;
248
+ }
249
+ /**
250
+ * UPDATE: Change the passphrase used to encrypt the private key.
251
+ * Requires the current passphrase for verification.
252
+ *
253
+ * @param currentPassphrase - Current passphrase
254
+ * @param newPassphrase - New passphrase
255
+ * @returns This instance for chaining
256
+ */
257
+ async updatePassphrase(currentPassphrase, newPassphrase) {
258
+ try {
259
+ // Validate inputs
260
+ MajikKeyValidator.validatePassphrase(currentPassphrase, "Current passphrase");
261
+ MajikKeyValidator.validatePassphrase(newPassphrase, "New passphrase");
262
+ // Verify current passphrase
263
+ const isValid = await MajikKey.isPassphraseValid(this.toKeyIdentity(), currentPassphrase);
264
+ if (!isValid) {
265
+ throw new MajikKeyError("Current passphrase is incorrect");
266
+ }
267
+ // Decrypt with current passphrase
268
+ const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
269
+ const privateKeyBuffer = await MajikKey.decryptPrivateKey(this._encryptedPrivateKey, currentPassphrase, salt);
270
+ // Re-encrypt with new passphrase and new salt
271
+ const newSalt = generateRandomBytes(16);
272
+ const newEncryptedPrivateKey = await MajikKey.encryptPrivateKey(privateKeyBuffer, newPassphrase, newSalt);
273
+ // Update encrypted state
274
+ this._encryptedPrivateKey = newEncryptedPrivateKey;
275
+ this._encryptedPrivateKeyBase64 = arrayBufferToBase64(newEncryptedPrivateKey);
276
+ this._salt = arrayToBase64(newSalt);
277
+ return this;
278
+ }
279
+ catch (err) {
280
+ if (err instanceof MajikKeyError)
281
+ throw err;
282
+ throw new MajikKeyError("Failed to update passphrase", err);
283
+ }
284
+ }
285
+ /**
286
+ * DELETE: Securely lock this MajikKey by clearing private keys from memory.
287
+ * The encrypted private key remains stored for future unlocking.
288
+ *
289
+ * @returns This instance for chaining
290
+ */
291
+ lock() {
292
+ // Clear private key from memory
293
+ this._privateKey = undefined;
294
+ this._privateKeyBase64 = undefined;
295
+ return this;
296
+ }
297
+ /* ================================
298
+ * Unlock/Lock Operations
299
+ * ================================ */
300
+ /**
301
+ * Unlock this MajikKey by decrypting the private key with the passphrase.
302
+ * Sets the private key in memory for cryptographic operations.
303
+ *
304
+ * @param passphrase - Passphrase to decrypt the private key
305
+ * @returns This instance for chaining
306
+ * @throws MajikKeyError if passphrase is incorrect or key is already unlocked
307
+ */
308
+ async unlock(passphrase) {
309
+ try {
310
+ // Check if already unlocked
311
+ if (this.isUnlocked) {
312
+ throw new MajikKeyError("MajikKey is already unlocked");
313
+ }
314
+ // Validate passphrase
315
+ MajikKeyValidator.validatePassphrase(passphrase);
316
+ // Decrypt private key
317
+ const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
318
+ const privateKeyBuffer = await MajikKey.decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt);
319
+ // Import as CryptoKey
320
+ const privateKey = await crypto.subtle.importKey("raw", privateKeyBuffer, KEY_ALGO, true, ["sign"]);
321
+ // Set unlocked state
322
+ this._privateKey = privateKey;
323
+ this._privateKeyBase64 = arrayBufferToBase64(privateKeyBuffer);
324
+ return this;
325
+ }
326
+ catch (err) {
327
+ if (err instanceof MajikKeyError)
328
+ throw err;
329
+ throw new MajikKeyError("Failed to unlock MajikKey - incorrect passphrase or corrupted data", err);
330
+ }
331
+ }
332
+ /**
333
+ * Verify that the encrypted private key can be decrypted with passphrase
334
+ */
335
+ async verify(passphrase) {
336
+ return MajikKey.isPassphraseValid(this.toKeyIdentity(), passphrase);
337
+ }
338
+ /**
339
+ * Get the private key (only available when unlocked).
340
+ *
341
+ * @returns The private key
342
+ * @throws MajikKeyError if the key is locked
343
+ */
344
+ getPrivateKey() {
345
+ if (this.isLocked) {
346
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
347
+ }
348
+ return this._privateKey;
349
+ }
350
+ /**
351
+ * Get the private key as base64 (only available when unlocked).
352
+ *
353
+ * @returns The private key in base64 format
354
+ * @throws MajikKeyError if the key is locked
355
+ */
356
+ getPrivateKeyBase64() {
357
+ if (this.isLocked) {
358
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
359
+ }
360
+ return this._privateKeyBase64;
361
+ }
362
+ /* ================================
363
+ * Serialization
364
+ * ================================ */
365
+ /**
366
+ * Export this MajikKey to JSON format (safe for storage).
367
+ * Private keys are never included in the JSON output.
368
+ *
369
+ * @returns JSON representation of this MajikKey
370
+ */
371
+ toJSON() {
372
+ return {
373
+ id: this._id,
374
+ label: this._label,
375
+ publicKey: this._publicKeyBase64,
376
+ fingerprint: this._fingerprint,
377
+ encryptedPrivateKey: this._encryptedPrivateKeyBase64,
378
+ salt: this._salt,
379
+ backup: this._backup,
380
+ timestamp: this._timestamp.toISOString(),
381
+ };
382
+ }
383
+ /**
384
+ * Export this MajikKey to a JSON string.
385
+ *
386
+ * @param pretty - Whether to pretty-print the JSON
387
+ * @returns JSON string representation
388
+ */
389
+ toString(pretty = false) {
390
+ return JSON.stringify(this.toJSON(), null, pretty ? 2 : 0);
391
+ }
392
+ /* ================================
393
+ * Utility Methods
394
+ * ================================ */
395
+ /**
396
+ * Generate a new BIP39 mnemonic phrase.
397
+ *
398
+ * @param strength - Entropy strength in bits (128 = 12 words, 256 = 24 words)
399
+ * @returns A new mnemonic phrase
400
+ */
401
+ static generateMnemonic(strength = 128) {
402
+ if (strength !== 128 && strength !== 256) {
403
+ throw new MajikKeyError("Strength must be 128 (12 words) or 256 (24 words)");
404
+ }
405
+ return generateMnemonic(wordlist, strength);
406
+ }
407
+ /**
408
+ * Validate a BIP39 mnemonic phrase.
409
+ *
410
+ * @param mnemonic - Mnemonic phrase to validate
411
+ * @returns true if valid, false otherwise
412
+ */
413
+ static validateMnemonic(mnemonic) {
414
+ try {
415
+ MajikKeyValidator.validateMnemonic(mnemonic);
416
+ return true;
417
+ }
418
+ catch {
419
+ return false;
420
+ }
421
+ }
422
+ /**
423
+ * Create a MajikContact from this MajikKey.
424
+ *
425
+ * @returns A MajikContact instance
426
+ */
427
+ toContact() {
428
+ return new MajikContact({
429
+ id: this._id,
430
+ publicKey: this._publicKey,
431
+ fingerprint: this._fingerprint,
432
+ meta: { label: this._label },
433
+ });
434
+ }
435
+ /**
436
+ * Convert to internal MajikKeyIdentity format (for backward compatibility).
437
+ * Note: Only includes privateKey if unlocked.
438
+ *
439
+ * @returns MajikKeyIdentity object
440
+ */
441
+ toKeyIdentity() {
442
+ if (this.isLocked) {
443
+ throw new MajikKeyError("Cannot convert locked MajikKey to KeyIdentity. Unlock first.");
444
+ }
445
+ return {
446
+ id: this._id,
447
+ publicKey: this._publicKey,
448
+ fingerprint: this._fingerprint,
449
+ privateKey: this._privateKey,
450
+ encryptedPrivateKey: this._encryptedPrivateKey,
451
+ salt: this._salt,
452
+ };
453
+ }
454
+ /**
455
+ * Convert to internal SerializedIdentity format (for Majik Message).
456
+ *
457
+ * @returns SerializedIdentity object
458
+ */
459
+ toSerializedIdentity() {
460
+ if (this.isLocked) {
461
+ throw new MajikKeyError("Cannot convert locked MajikKey to SerializedIdentity. Unlock first.");
462
+ }
463
+ return {
464
+ id: this._id,
465
+ publicKey: this._publicKeyBase64,
466
+ fingerprint: this._fingerprint,
467
+ encryptedPrivateKey: this._encryptedPrivateKeyBase64,
468
+ salt: this._salt,
469
+ };
470
+ }
471
+ async toMajikMessageIdentity(user, options) {
472
+ MajikKeyValidator.assert(user, "MajikUser is required");
473
+ const userValidResult = user.validate();
474
+ if (!userValidResult.isValid) {
475
+ throw new Error(`Invalid MajikUser: ${userValidResult.errors.join(", ")}`);
476
+ }
477
+ const keyContact = await this.toContact().toJSON();
478
+ const newMajikMessageIdentity = MajikMessageIdentity.create(user, keyContact, options);
479
+ return newMajikMessageIdentity;
480
+ }
481
+ /* ================================
482
+ * Static Backup Methods
483
+ * ================================ */
484
+ /**
485
+ * Export a mnemonic-encrypted backup for this MajikKey.
486
+ * Requires the key to be unlocked.
487
+ *
488
+ * @param mnemonic - The original mnemonic phrase
489
+ * @returns Base64-encoded backup string
490
+ */
491
+ async exportMnemonicBackup(mnemonic) {
492
+ try {
493
+ if (this.isLocked) {
494
+ throw new MajikKeyError("MajikKey must be unlocked to export backup");
495
+ }
496
+ MajikKeyValidator.validateMnemonic(mnemonic);
497
+ const identity = this.toKeyIdentity();
498
+ return await MajikKey.exportIdentityMnemonicBackup(identity, mnemonic);
499
+ }
500
+ catch (err) {
501
+ if (err instanceof MajikKeyError)
502
+ throw err;
503
+ throw new MajikKeyError("Failed to export mnemonic backup", err);
504
+ }
505
+ }
506
+ /**
507
+ * Import a MajikKey from a mnemonic-encrypted backup.
508
+ *
509
+ * @param backup - Base64-encoded backup string
510
+ * @param mnemonic - The mnemonic phrase used to encrypt the backup
511
+ * @param passphrase - Passphrase to encrypt the imported key
512
+ * @param label - Optional label for the imported key
513
+ * @returns A new unlocked MajikKey instance
514
+ */
515
+ static async importFromMnemonicBackup(backup, mnemonic, passphrase, label) {
516
+ try {
517
+ // Validate inputs
518
+ if (!backup || typeof backup !== "string") {
519
+ throw new MajikKeyError("Backup must be a non-empty string");
520
+ }
521
+ MajikKeyValidator.validateMnemonic(mnemonic);
522
+ MajikKeyValidator.validatePassphrase(passphrase);
523
+ MajikKeyValidator.validateLabel(label);
524
+ // Decode backup
525
+ const backupJson = base64ToUtf8(backup);
526
+ const parsed = JSON.parse(backupJson);
527
+ if (!parsed.iv ||
528
+ !parsed.ciphertext ||
529
+ !parsed.publicKey ||
530
+ !parsed.fingerprint) {
531
+ throw new MajikKeyError("Invalid backup format");
532
+ }
533
+ // Derive key from mnemonic
534
+ const fullKey = await this.deriveKeyFromMnemonic(mnemonic);
535
+ const iv = new Uint8Array(base64ToArrayBuffer(parsed.iv));
536
+ const ciphertext = base64ToArrayBuffer(parsed.ciphertext);
537
+ const rawPrivate = await crypto.subtle
538
+ .decrypt({ name: "AES-GCM", iv }, fullKey, ciphertext)
539
+ .catch((err) => {
540
+ throw new MajikKeyError("Failed to decrypt backup - invalid mnemonic or corrupted data", err);
541
+ });
542
+ let privateKey;
543
+ try {
544
+ privateKey = await crypto.subtle.importKey("raw", rawPrivate, KEY_ALGO, true, ["deriveKey", "deriveBits"]);
545
+ }
546
+ catch (e) {
547
+ // WebCrypto does not support X25519 – store raw key
548
+ privateKey = {
549
+ type: "private",
550
+ raw: new Uint8Array(rawPrivate),
551
+ };
552
+ }
553
+ let publicKey;
554
+ const rawPublic = base64ToArrayBuffer(parsed.publicKey);
555
+ try {
556
+ publicKey = await crypto.subtle.importKey("raw", rawPublic, KEY_ALGO, true, []);
557
+ }
558
+ catch (e) {
559
+ // WebCrypto may not support X25519; return a raw-key wrapper as fallback
560
+ const ua = new Uint8Array(rawPublic);
561
+ const wrapper = { type: "public", raw: ua };
562
+ publicKey = wrapper;
563
+ }
564
+ // Encrypt with new passphrase
565
+ const newSalt = generateRandomBytes(16);
566
+ const encryptedPrivateKey = await this.encryptPrivateKey(rawPrivate, passphrase, newSalt);
567
+ // Create unlocked instance
568
+ return new MajikKey({
569
+ id: parsed?.id || parsed.fingerprint,
570
+ publicKey,
571
+ publicKeyBase64: parsed.publicKey,
572
+ fingerprint: parsed.fingerprint,
573
+ encryptedPrivateKey,
574
+ encryptedPrivateKeyBase64: arrayBufferToBase64(encryptedPrivateKey),
575
+ salt: arrayToBase64(newSalt),
576
+ backup,
577
+ label: label || "",
578
+ timestamp: new Date(),
579
+ // Unlocked state
580
+ privateKey,
581
+ privateKeyBase64: arrayBufferToBase64(rawPrivate),
582
+ });
583
+ }
584
+ catch (err) {
585
+ if (err instanceof MajikKeyError)
586
+ throw err;
587
+ throw new MajikKeyError("Failed to import from mnemonic backup", err);
588
+ }
589
+ }
590
+ /* ================================
591
+ * Private Static Helpers
592
+ * ================================ */
593
+ /**
594
+ * Create a deterministic identity from a mnemonic and encrypt it with passphrase.
595
+ * The identity `id` is set to the fingerprint for stable referencing.
596
+ */
597
+ static async createIdentityFromMnemonic(mnemonic, passphrase) {
598
+ try {
599
+ const identity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
600
+ const id = identity.fingerprint; // stable id
601
+ // Export private key
602
+ let exportedPrivate;
603
+ try {
604
+ exportedPrivate = await crypto.subtle.exportKey("raw", identity.privateKey);
605
+ }
606
+ catch (e) {
607
+ const anyPriv = identity.privateKey;
608
+ if (anyPriv?.raw instanceof Uint8Array) {
609
+ exportedPrivate = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
610
+ }
611
+ else {
612
+ throw e;
613
+ }
614
+ }
615
+ // Encrypt private key
616
+ const salt = generateRandomBytes(16);
617
+ const encryptedPrivateKey = await this.encryptPrivateKey(exportedPrivate, passphrase, salt);
618
+ return {
619
+ id,
620
+ publicKey: identity.publicKey,
621
+ fingerprint: identity.fingerprint,
622
+ encryptedPrivateKey,
623
+ privateKey: identity.privateKey,
624
+ salt: arrayToBase64(salt),
625
+ };
626
+ }
627
+ catch (err) {
628
+ throw new MajikKeyError("Failed to create identity from mnemonic", err);
629
+ }
630
+ }
631
+ /**
632
+ * Export an identity encrypted with a mnemonic-derived key.
633
+ * Returns a base64 string containing iv+ciphertext and publicKey/fingerprint in JSON.
634
+ */
635
+ static async exportIdentityMnemonicBackup(identity, mnemonic) {
636
+ try {
637
+ if (!identity?.privateKey) {
638
+ throw new MajikKeyError("Identity must have privateKey to export backup");
639
+ }
640
+ // Export keys
641
+ let privRawBuf;
642
+ let pubRawBuf;
643
+ try {
644
+ privRawBuf = await crypto.subtle.exportKey("raw", identity.privateKey);
645
+ pubRawBuf = await crypto.subtle.exportKey("raw", identity.publicKey);
646
+ }
647
+ catch (e) {
648
+ const anyPriv = identity.privateKey;
649
+ const anyPub = identity.publicKey;
650
+ if (anyPriv?.raw instanceof Uint8Array) {
651
+ privRawBuf = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
652
+ }
653
+ else {
654
+ throw e;
655
+ }
656
+ if (anyPub?.raw instanceof Uint8Array) {
657
+ pubRawBuf = anyPub.raw.buffer.slice(anyPub.raw.byteOffset, anyPub.raw.byteOffset + anyPub.raw.byteLength);
658
+ }
659
+ else {
660
+ throw e;
661
+ }
662
+ }
663
+ // Derive AES key from mnemonic
664
+ const salt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
665
+ const keyBytes = deriveKeyFromMnemonic(mnemonic, salt);
666
+ const iv = generateRandomBytes(IV_LENGTH);
667
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(privRawBuf));
668
+ const packaged = {
669
+ id: identity.id,
670
+ iv: arrayToBase64(iv),
671
+ ciphertext: arrayToBase64(ciphertext),
672
+ publicKey: arrayBufferToBase64(pubRawBuf),
673
+ fingerprint: identity.fingerprint,
674
+ };
675
+ return utf8ToBase64(JSON.stringify(packaged));
676
+ }
677
+ catch (err) {
678
+ throw new MajikKeyError("Failed to export identity mnemonic backup", err);
679
+ }
680
+ }
681
+ /**
682
+ * Encrypt a private key with a passphrase.
683
+ */
684
+ static async encryptPrivateKey(buffer, passphrase, salt) {
685
+ try {
686
+ const keyBytes = deriveKeyFromPassphrase(passphrase, salt);
687
+ const iv = generateRandomBytes(IV_LENGTH);
688
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(buffer));
689
+ return concatUint8Arrays(iv, ciphertext).buffer;
690
+ }
691
+ catch (err) {
692
+ throw new MajikKeyError("Failed to encrypt private key", err);
693
+ }
694
+ }
695
+ /**
696
+ * Decrypt a private key with a passphrase.
697
+ */
698
+ static async decryptPrivateKey(buffer, passphrase, salt) {
699
+ try {
700
+ const keyBytes = deriveKeyFromPassphrase(passphrase, salt);
701
+ const full = new Uint8Array(buffer);
702
+ const iv = full.slice(0, IV_LENGTH);
703
+ const ciphertext = full.slice(IV_LENGTH);
704
+ const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
705
+ if (!plain) {
706
+ throw new MajikKeyError("Decryption failed - authentication tag mismatch");
707
+ }
708
+ return plain.buffer;
709
+ }
710
+ catch (err) {
711
+ if (err instanceof MajikKeyError)
712
+ throw err;
713
+ throw new MajikKeyError("Failed to decrypt private key", err);
714
+ }
715
+ }
716
+ static async deriveKeyFromMnemonic(mnemonic) {
717
+ const salt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
718
+ const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(mnemonic), { name: "PBKDF2" }, false, ["deriveKey"]);
719
+ return crypto.subtle.deriveKey({
720
+ name: "PBKDF2",
721
+ salt,
722
+ iterations: 200_000,
723
+ hash: "SHA-256",
724
+ }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
725
+ }
726
+ /**
727
+ * Validate whether a passphrase can decrypt the stored private key.
728
+ * Does NOT unlock or mutate any in-memory state.
729
+ */
730
+ static async isPassphraseValid(identity, passphrase) {
731
+ if (!passphrase)
732
+ return false;
733
+ try {
734
+ if (!identity?.encryptedPrivateKey)
735
+ return false;
736
+ const salt = identity.salt
737
+ ? new Uint8Array(base64ToArrayBuffer(identity.salt))
738
+ : new TextEncoder().encode(MAJIK_SALT);
739
+ // Attempt authenticated decryption
740
+ await this.decryptPrivateKey(identity.encryptedPrivateKey, passphrase, salt);
741
+ return true;
742
+ }
743
+ catch {
744
+ return false;
745
+ }
746
+ }
747
+ /**
748
+ * Export a CryptoKey to base64 string.
749
+ */
750
+ static async exportKeyToBase64(key) {
751
+ try {
752
+ const anyKey = key;
753
+ if (anyKey && anyKey.raw instanceof Uint8Array) {
754
+ return arrayBufferToBase64(anyKey.raw.buffer);
755
+ }
756
+ const raw = await crypto.subtle.exportKey("raw", key);
757
+ return arrayBufferToBase64(raw);
758
+ }
759
+ catch (err) {
760
+ throw new MajikKeyError("Failed to export key to base64", err);
761
+ }
762
+ }
763
+ }