@majikah/majik-key 0.1.1 → 0.1.2

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/dist/majik-key.js CHANGED
@@ -1,38 +1,56 @@
1
+ /**
2
+ * MajikKey.ts
3
+ *
4
+ * Seed phrase account library for Majik Message.
5
+ *
6
+ * Every account stores TWO keypairs derived deterministically from the mnemonic:
7
+ * 1. X25519 (Curve25519) — fingerprint, contact identity, legacy message compat
8
+ * 2. ML-KEM-768 (FIPS-203) — post-quantum key encapsulation for v3 envelopes
9
+ *
10
+ * Both are derived from the 64-byte BIP-39 seed:
11
+ * seed[0..32] → Ed25519 → X25519 via ed2curve
12
+ * seed[0..64] → ml_kem768.keygen(seed) — full seed, deterministic
13
+ *
14
+ * KDF versioning (passphrase encryption at rest):
15
+ * v1 — PBKDF2-SHA256, 250k iterations (legacy read-only)
16
+ * v2 — Argon2id, 128 MB / 4t / 4p (all new accounts)
17
+ *
18
+ * Migration policy:
19
+ * Old accounts (v1, no ML-KEM keys) are fully upgraded on first import
20
+ * via importFromMnemonicBackup(). The mnemonic is always available at that
21
+ * point, so ML-KEM keys can be deterministically re-derived and stored.
22
+ * No partial migration — either fully upgraded or not upgraded yet.
23
+ */
1
24
  import { generateMnemonic } from "@scure/bip39";
2
- import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromMnemonic, deriveKeyFromPassphrase, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider";
25
+ import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromPassphraseArgon2, deriveKeyFromMnemonicArgon2, deriveKeyFromPassphrase, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider";
3
26
  import { EncryptionEngine } from "./core/crypto/encryption-engine";
4
27
  import { MajikContact } from "./core/majik-contact";
5
28
  import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, concatUint8Arrays, utf8ToBase64, base64ToUtf8, seedStringToArray, seedArrayToString, } from "./core/utils";
6
29
  import { wordlist } from "@scure/bip39/wordlists/english";
7
- import { KEY_ALGO, MAJIK_MNEMONIC_SALT, MAJIK_SALT, } from "./core/crypto/constants";
30
+ import { KDF_VERSION, KEY_ALGO, MAJIK_MNEMONIC_SALT, } from "./core/crypto/constants";
8
31
  import { MajikKeyValidator } from "./core/validator";
9
32
  import { MajikKeyError } from "./core/error";
10
33
  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
- */
34
+ const SALT_SIZE = 32;
35
+ // ─── MajikKey ─────────────────────────────────────────────────────────────────
17
36
  export class MajikKey {
18
- // Immutable properties
19
37
  _id;
20
38
  _publicKey;
21
39
  _publicKeyBase64;
22
40
  _fingerprint;
23
41
  _backup;
24
42
  _timestamp;
25
- // Mutable encrypted state
26
43
  _encryptedPrivateKey;
27
44
  _encryptedPrivateKeyBase64;
28
45
  _salt;
29
46
  _label;
30
- // Unlocked state (optional - only present when unlocked)
47
+ _kdfVersion;
48
+ _mlKemPublicKey;
49
+ _mlKemSecretKey;
50
+ _encryptedMlKemSecretKey;
51
+ _encryptedMlKemSecretKeyBase64;
31
52
  _privateKey;
32
53
  _privateKeyBase64;
33
- /* ================================
34
- * Constructor
35
- * ================================ */
36
54
  constructor(options) {
37
55
  this._id = options.id;
38
56
  this._publicKey = options.publicKey;
@@ -44,13 +62,15 @@ export class MajikKey {
44
62
  this._backup = options.backup;
45
63
  this._label = options.label || "";
46
64
  this._timestamp = options.timestamp || new Date();
47
- // Optional unlocked state
65
+ this._kdfVersion = options.kdfVersion ?? KDF_VERSION.PBKDF2;
66
+ this._mlKemPublicKey = options.mlKemPublicKey;
67
+ this._mlKemSecretKey = options.mlKemSecretKey;
68
+ this._encryptedMlKemSecretKey = options.encryptedMlKemSecretKey;
69
+ this._encryptedMlKemSecretKeyBase64 = options.encryptedMlKemSecretKeyBase64;
48
70
  this._privateKey = options.privateKey;
49
71
  this._privateKeyBase64 = options.privateKeyBase64;
50
72
  }
51
- /* ================================
52
- * Public Getters
53
- * ================================ */
73
+ // ── Getters ─────────────────────────────────────────────────────────────────
54
74
  get id() {
55
75
  return this._id;
56
76
  }
@@ -72,15 +92,30 @@ export class MajikKey {
72
92
  get timestamp() {
73
93
  return this._timestamp;
74
94
  }
95
+ get kdfVersion() {
96
+ return this._kdfVersion;
97
+ }
98
+ get isArgon2id() {
99
+ return this._kdfVersion === KDF_VERSION.ARGON2ID;
100
+ }
75
101
  get isLocked() {
76
102
  return this._privateKey === undefined;
77
103
  }
78
104
  get isUnlocked() {
79
105
  return this._privateKey !== undefined;
80
106
  }
81
- /**
82
- * Get safe metadata (no sensitive data)
83
- */
107
+ get mlKemPublicKey() {
108
+ return this._mlKemPublicKey;
109
+ }
110
+ get mlKemSecretKey() {
111
+ return this._mlKemSecretKey;
112
+ }
113
+ get hasMlKem() {
114
+ return this._mlKemPublicKey !== undefined;
115
+ }
116
+ get isFullyUpgraded() {
117
+ return this.isArgon2id && this.hasMlKem;
118
+ }
84
119
  get metadata() {
85
120
  return {
86
121
  id: this.id,
@@ -88,34 +123,20 @@ export class MajikKey {
88
123
  label: this.label,
89
124
  timestamp: this.timestamp,
90
125
  isLocked: this.isLocked,
126
+ kdfVersion: this.kdfVersion,
127
+ hasMlKem: this.hasMlKem,
91
128
  };
92
129
  }
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
- */
130
+ // ── CREATE ──────────────────────────────────────────────────────────────────
105
131
  static async create(mnemonic, passphrase, label) {
106
132
  try {
107
- // Validate inputs
108
133
  MajikKeyValidator.validateMnemonic(mnemonic);
109
134
  MajikKeyValidator.validatePassphrase(passphrase);
110
135
  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
136
+ const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase);
137
+ const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
138
+ const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
139
+ const backup = await MajikKey._exportMnemonicBackup(identity, mnemonic);
119
140
  return new MajikKey({
120
141
  id: identity.id,
121
142
  publicKey: identity.publicKey,
@@ -127,7 +148,11 @@ export class MajikKey {
127
148
  backup,
128
149
  label: label || "",
129
150
  timestamp: new Date(),
130
- // Unlocked state
151
+ kdfVersion: KDF_VERSION.ARGON2ID,
152
+ mlKemPublicKey: identity.mlKemPublicKey,
153
+ mlKemSecretKey: identity.mlKemSecretKey,
154
+ encryptedMlKemSecretKey: identity.encryptedMlKemSecretKey,
155
+ encryptedMlKemSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlKemSecretKey),
131
156
  privateKey: identity.privateKey,
132
157
  privateKeyBase64,
133
158
  });
@@ -138,83 +163,24 @@ export class MajikKey {
138
163
  throw new MajikKeyError("Failed to create MajikKey", err);
139
164
  }
140
165
  }
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
- */
166
+ // ── READ ────────────────────────────────────────────────────────────────────
210
167
  static fromJSON(json) {
211
168
  try {
212
169
  const parsed = typeof json === "string" ? JSON.parse(json) : json;
213
170
  const validated = MajikKeyValidator.validateJSON(parsed);
214
- // Convert base64 to CryptoKey/ArrayBuffer
171
+ const anyParsed = parsed;
215
172
  const publicKeyBuffer = base64ToArrayBuffer(validated.publicKey);
216
173
  const encryptedPrivateKeyBuffer = base64ToArrayBuffer(validated.encryptedPrivateKey);
217
- // Create locked instance (no private key)
174
+ let mlKemPublicKey;
175
+ if (anyParsed.mlKemPublicKey) {
176
+ mlKemPublicKey = new Uint8Array(base64ToArrayBuffer(anyParsed.mlKemPublicKey));
177
+ }
178
+ let encryptedMlKemSecretKey;
179
+ let encryptedMlKemSecretKeyBase64;
180
+ if (anyParsed.encryptedMlKemSecretKey) {
181
+ encryptedMlKemSecretKeyBase64 = anyParsed.encryptedMlKemSecretKey;
182
+ encryptedMlKemSecretKey = base64ToArrayBuffer(anyParsed.encryptedMlKemSecretKey);
183
+ }
218
184
  return new MajikKey({
219
185
  id: validated.id,
220
186
  publicKey: { raw: new Uint8Array(publicKeyBuffer) },
@@ -226,7 +192,11 @@ export class MajikKey {
226
192
  backup: validated.backup,
227
193
  label: validated.label || "",
228
194
  timestamp: new Date(validated.timestamp),
229
- // No private key - locked state
195
+ kdfVersion: validated.kdfVersion ??
196
+ KDF_VERSION.PBKDF2,
197
+ mlKemPublicKey,
198
+ encryptedMlKemSecretKey,
199
+ encryptedMlKemSecretKeyBase64,
230
200
  });
231
201
  }
232
202
  catch (err) {
@@ -235,45 +205,64 @@ export class MajikKey {
235
205
  throw new MajikKeyError("Failed to parse MajikKey from JSON", err);
236
206
  }
237
207
  }
238
- /**
239
- * UPDATE: Change the label of this MajikKey.
240
- *
241
- * @param newLabel - New label value
242
- * @returns This instance for chaining
243
- */
208
+ // ── MnemonicJSON ─────────────────────────────────────────────────────────────
209
+ toMnemonicJSON(mnemonic, passphrase) {
210
+ if (this.isLocked)
211
+ throw new MajikKeyError("Cannot export locked MajikKey to MnemonicJSON. Unlock first.");
212
+ MajikKeyValidator.validateMnemonic(mnemonic);
213
+ if (passphrase !== undefined)
214
+ MajikKeyValidator.validatePassphrase(passphrase, "Passphrase");
215
+ return {
216
+ id: this._backup,
217
+ seed: seedStringToArray(mnemonic.trim()),
218
+ phrase: passphrase?.trim() || undefined,
219
+ };
220
+ }
221
+ static async fromMnemonicJSON(mnemonicJson, passphrase, label) {
222
+ try {
223
+ const parsed = typeof mnemonicJson === "string"
224
+ ? JSON.parse(mnemonicJson)
225
+ : mnemonicJson;
226
+ if (!parsed.id || !parsed.seed || !Array.isArray(parsed.seed))
227
+ throw new MajikKeyError("Invalid MnemonicJSON");
228
+ const mnemonic = seedArrayToString(parsed.seed);
229
+ MajikKeyValidator.validateMnemonic(mnemonic);
230
+ return await MajikKey.create(mnemonic, passphrase, label);
231
+ }
232
+ catch (err) {
233
+ if (err instanceof MajikKeyError)
234
+ throw err;
235
+ throw new MajikKeyError("Failed to create MajikKey from MnemonicJSON", err);
236
+ }
237
+ }
238
+ // ── UPDATE ───────────────────────────────────────────────────────────────────
244
239
  updateLabel(newLabel) {
245
240
  MajikKeyValidator.validateLabel(newLabel);
246
241
  this._label = newLabel || "";
247
242
  return this;
248
243
  }
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
244
  async updatePassphrase(currentPassphrase, newPassphrase) {
258
245
  try {
259
- // Validate inputs
260
246
  MajikKeyValidator.validatePassphrase(currentPassphrase, "Current passphrase");
261
247
  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
248
  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
249
+ const privateKeyBuffer = await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, currentPassphrase, salt, this._kdfVersion);
250
+ let mlKemSecretKeyBytes;
251
+ if (this._encryptedMlKemSecretKey) {
252
+ mlKemSecretKeyBytes = await MajikKey._decryptMlKemSecretKey(this._encryptedMlKemSecretKey, currentPassphrase, salt);
253
+ }
254
+ const newSalt = generateRandomBytes(SALT_SIZE);
255
+ const { blob: newEncryptedPrivateKey } = await MajikKey._encryptPrivateKey(privateKeyBuffer, newPassphrase, newSalt);
274
256
  this._encryptedPrivateKey = newEncryptedPrivateKey;
275
257
  this._encryptedPrivateKeyBase64 = arrayBufferToBase64(newEncryptedPrivateKey);
276
258
  this._salt = arrayToBase64(newSalt);
259
+ this._kdfVersion = KDF_VERSION.ARGON2ID;
260
+ if (mlKemSecretKeyBytes) {
261
+ const encMlKem = await MajikKey._encryptMlKemSecretKey(mlKemSecretKeyBytes, newPassphrase, newSalt);
262
+ this._encryptedMlKemSecretKey = encMlKem;
263
+ this._encryptedMlKemSecretKeyBase64 = arrayBufferToBase64(encMlKem);
264
+ this._mlKemSecretKey = mlKemSecretKeyBytes;
265
+ }
277
266
  return this;
278
267
  }
279
268
  catch (err) {
@@ -283,91 +272,95 @@ export class MajikKey {
283
272
  }
284
273
  }
285
274
  /**
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
275
+ * Migrate KDF from PBKDF2 to Argon2id without changing passphrase.
276
+ * NOTE: Does not add ML-KEM keys — use importFromMnemonicBackup() for full upgrade.
290
277
  */
278
+ async migrate(passphrase) {
279
+ try {
280
+ MajikKeyValidator.validatePassphrase(passphrase);
281
+ if (this._kdfVersion === KDF_VERSION.ARGON2ID)
282
+ return this;
283
+ const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
284
+ const privateKeyBuffer = await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt, KDF_VERSION.PBKDF2);
285
+ const newSalt = generateRandomBytes(SALT_SIZE);
286
+ const { blob } = await MajikKey._encryptPrivateKey(privateKeyBuffer, passphrase, newSalt);
287
+ this._encryptedPrivateKey = blob;
288
+ this._encryptedPrivateKeyBase64 = arrayBufferToBase64(blob);
289
+ this._salt = arrayToBase64(newSalt);
290
+ this._kdfVersion = KDF_VERSION.ARGON2ID;
291
+ return this;
292
+ }
293
+ catch (err) {
294
+ if (err instanceof MajikKeyError)
295
+ throw err;
296
+ throw new MajikKeyError("Failed to migrate MajikKey to Argon2id", err);
297
+ }
298
+ }
299
+ // ── LOCK / UNLOCK ────────────────────────────────────────────────────────────
291
300
  lock() {
292
- // Clear private key from memory
293
301
  this._privateKey = undefined;
294
302
  this._privateKeyBase64 = undefined;
303
+ this._mlKemSecretKey = undefined;
295
304
  return this;
296
305
  }
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
306
  async unlock(passphrase) {
309
307
  try {
310
- // Check if already unlocked
311
- if (this.isUnlocked) {
308
+ if (this.isUnlocked)
312
309
  throw new MajikKeyError("MajikKey is already unlocked");
313
- }
314
- // Validate passphrase
315
310
  MajikKeyValidator.validatePassphrase(passphrase);
316
- // Decrypt private key
317
311
  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
312
+ const privateKeyBuffer = await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt, this._kdfVersion);
313
+ let privateKey;
314
+ try {
315
+ privateKey = await crypto.subtle.importKey("raw", privateKeyBuffer, KEY_ALGO, true, ["sign"]);
316
+ }
317
+ catch {
318
+ privateKey = {
319
+ type: "private",
320
+ raw: new Uint8Array(privateKeyBuffer),
321
+ };
322
+ }
322
323
  this._privateKey = privateKey;
323
324
  this._privateKeyBase64 = arrayBufferToBase64(privateKeyBuffer);
325
+ if (this._encryptedMlKemSecretKey) {
326
+ this._mlKemSecretKey = await MajikKey._decryptMlKemSecretKey(this._encryptedMlKemSecretKey, passphrase, salt);
327
+ }
324
328
  return this;
325
329
  }
326
330
  catch (err) {
327
331
  if (err instanceof MajikKeyError)
328
332
  throw err;
329
- throw new MajikKeyError("Failed to unlock MajikKey - incorrect passphrase or corrupted data", err);
333
+ throw new MajikKeyError("Failed to unlock MajikKey — incorrect passphrase or corrupted data", err);
330
334
  }
331
335
  }
332
- /**
333
- * Verify that the encrypted private key can be decrypted with passphrase
334
- */
335
336
  async verify(passphrase) {
336
- return MajikKey.isPassphraseValid(this.toKeyIdentity(), passphrase);
337
+ try {
338
+ const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
339
+ await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt, this._kdfVersion);
340
+ return true;
341
+ }
342
+ catch {
343
+ return false;
344
+ }
337
345
  }
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
346
  getPrivateKey() {
345
- if (this.isLocked) {
347
+ if (this.isLocked)
346
348
  throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
347
- }
348
349
  return this._privateKey;
349
350
  }
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
351
  getPrivateKeyBase64() {
357
- if (this.isLocked) {
352
+ if (this.isLocked)
358
353
  throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
359
- }
360
354
  return this._privateKeyBase64;
361
355
  }
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
- */
356
+ getMlKemSecretKey() {
357
+ if (this.isLocked)
358
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
359
+ if (!this._mlKemSecretKey)
360
+ throw new MajikKeyError("No ML-KEM secret key — re-import via importFromMnemonicBackup() for full migration.");
361
+ return this._mlKemSecretKey;
362
+ }
363
+ // ── SERIALIZATION ────────────────────────────────────────────────────────────
371
364
  toJSON() {
372
365
  return {
373
366
  id: this._id,
@@ -378,38 +371,22 @@ export class MajikKey {
378
371
  salt: this._salt,
379
372
  backup: this._backup,
380
373
  timestamp: this._timestamp.toISOString(),
374
+ kdfVersion: this._kdfVersion,
375
+ mlKemPublicKey: this._mlKemPublicKey
376
+ ? arrayToBase64(this._mlKemPublicKey)
377
+ : undefined,
378
+ encryptedMlKemSecretKey: this._encryptedMlKemSecretKeyBase64,
381
379
  };
382
380
  }
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
381
  toString(pretty = false) {
390
382
  return JSON.stringify(this.toJSON(), null, pretty ? 2 : 0);
391
383
  }
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
- */
384
+ // ── UTILITY ──────────────────────────────────────────────────────────────────
401
385
  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
- }
386
+ if (strength !== 128 && strength !== 256)
387
+ throw new MajikKeyError("Strength must be 128 or 256");
405
388
  return generateMnemonic(wordlist, strength);
406
389
  }
407
- /**
408
- * Validate a BIP39 mnemonic phrase.
409
- *
410
- * @param mnemonic - Mnemonic phrase to validate
411
- * @returns true if valid, false otherwise
412
- */
413
390
  static validateMnemonic(mnemonic) {
414
391
  try {
415
392
  MajikKeyValidator.validateMnemonic(mnemonic);
@@ -419,11 +396,6 @@ export class MajikKey {
419
396
  return false;
420
397
  }
421
398
  }
422
- /**
423
- * Create a MajikContact from this MajikKey.
424
- *
425
- * @returns A MajikContact instance
426
- */
427
399
  toContact() {
428
400
  return new MajikContact({
429
401
  id: this._id,
@@ -432,16 +404,9 @@ export class MajikKey {
432
404
  meta: { label: this._label },
433
405
  });
434
406
  }
435
- /**
436
- * Convert to internal MajikKeyIdentity format (for backward compatibility).
437
- * Note: Only includes privateKey if unlocked.
438
- *
439
- * @returns MajikKeyIdentity object
440
- */
441
407
  toKeyIdentity() {
442
- if (this.isLocked) {
408
+ if (this.isLocked)
443
409
  throw new MajikKeyError("Cannot convert locked MajikKey to KeyIdentity. Unlock first.");
444
- }
445
410
  return {
446
411
  id: this._id,
447
412
  publicKey: this._publicKey,
@@ -449,17 +414,14 @@ export class MajikKey {
449
414
  privateKey: this._privateKey,
450
415
  encryptedPrivateKey: this._encryptedPrivateKey,
451
416
  salt: this._salt,
417
+ kdfVersion: this._kdfVersion,
418
+ mlKemPublicKey: this._mlKemPublicKey,
419
+ mlKemSecretKey: this._mlKemSecretKey,
452
420
  };
453
421
  }
454
- /**
455
- * Convert to internal SerializedIdentity format (for Majik Message).
456
- *
457
- * @returns SerializedIdentity object
458
- */
459
422
  toSerializedIdentity() {
460
- if (this.isLocked) {
423
+ if (this.isLocked)
461
424
  throw new MajikKeyError("Cannot convert locked MajikKey to SerializedIdentity. Unlock first.");
462
- }
463
425
  return {
464
426
  id: this._id,
465
427
  publicKey: this._publicKeyBase64,
@@ -471,57 +433,37 @@ export class MajikKey {
471
433
  async toMajikMessageIdentity(user, options) {
472
434
  MajikKeyValidator.assert(user, "MajikUser is required");
473
435
  const userValidResult = user.validate();
474
- if (!userValidResult.isValid) {
436
+ if (!userValidResult.isValid)
475
437
  throw new Error(`Invalid MajikUser: ${userValidResult.errors.join(", ")}`);
476
- }
477
438
  const keyContact = await this.toContact().toJSON();
478
- const newMajikMessageIdentity = MajikMessageIdentity.create(user, keyContact, options);
479
- return newMajikMessageIdentity;
439
+ return MajikMessageIdentity.create(user, keyContact, options);
480
440
  }
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
- */
441
+ // ── BACKUP ───────────────────────────────────────────────────────────────────
491
442
  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
- }
443
+ if (this.isLocked)
444
+ throw new MajikKeyError("MajikKey must be unlocked to export backup");
445
+ MajikKeyValidator.validateMnemonic(mnemonic);
446
+ return MajikKey._exportMnemonicBackup(this.toKeyIdentity(), mnemonic);
505
447
  }
506
448
  /**
507
449
  * Import a MajikKey from a mnemonic-encrypted backup.
508
450
  *
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
451
+ * This is the FULL MIGRATION PATH for old accounts — Argon2id + ML-KEM in one step:
452
+ * 1. Verify the backup decrypts correctly (proves mnemonic is correct)
453
+ * 2. Re-derive the complete identity from the mnemonic (X25519 + ML-KEM-768)
454
+ * 3. Encrypt both private keys with Argon2id (v2) + fresh 32-byte salt
455
+ * 4. Return a fully-upgraded MajikKey with hasMlKem: true, isArgon2id: true
456
+ *
457
+ * Old accounts without ML-KEM keys become fully post-quantum capable
458
+ * automatically — no extra user steps. The mnemonic is the source of truth.
514
459
  */
515
460
  static async importFromMnemonicBackup(backup, mnemonic, passphrase, label) {
516
461
  try {
517
- // Validate inputs
518
- if (!backup || typeof backup !== "string") {
462
+ if (!backup || typeof backup !== "string")
519
463
  throw new MajikKeyError("Backup must be a non-empty string");
520
- }
521
464
  MajikKeyValidator.validateMnemonic(mnemonic);
522
465
  MajikKeyValidator.validatePassphrase(passphrase);
523
466
  MajikKeyValidator.validateLabel(label);
524
- // Decode backup
525
467
  const backupJson = base64ToUtf8(backup);
526
468
  const parsed = JSON.parse(backupJson);
527
469
  if (!parsed.iv ||
@@ -530,55 +472,33 @@ export class MajikKey {
530
472
  !parsed.fingerprint) {
531
473
  throw new MajikKeyError("Invalid backup format");
532
474
  }
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
475
+ const backupKdfVersion = parsed.backupKdfVersion ??
476
+ KDF_VERSION.PBKDF2;
477
+ // Verify mnemonic is correct before doing expensive re-derivation
478
+ await MajikKey._verifyBackupDecryption(parsed.iv, parsed.ciphertext, mnemonic, backupKdfVersion);
479
+ // Re-derive complete identity from mnemonic — gets ML-KEM for free
480
+ const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase);
481
+ const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
482
+ const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
483
+ const id = parsed.id || identity.id;
568
484
  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),
485
+ id,
486
+ publicKey: identity.publicKey,
487
+ publicKeyBase64,
488
+ fingerprint: identity.fingerprint,
489
+ encryptedPrivateKey: identity.encryptedPrivateKey,
490
+ encryptedPrivateKeyBase64: arrayBufferToBase64(identity.encryptedPrivateKey),
491
+ salt: identity.salt,
576
492
  backup,
577
493
  label: label || "",
578
494
  timestamp: new Date(),
579
- // Unlocked state
580
- privateKey,
581
- privateKeyBase64: arrayBufferToBase64(rawPrivate),
495
+ kdfVersion: KDF_VERSION.ARGON2ID,
496
+ mlKemPublicKey: identity.mlKemPublicKey,
497
+ mlKemSecretKey: identity.mlKemSecretKey,
498
+ encryptedMlKemSecretKey: identity.encryptedMlKemSecretKey,
499
+ encryptedMlKemSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlKemSecretKey),
500
+ privateKey: identity.privateKey,
501
+ privateKeyBase64,
582
502
  });
583
503
  }
584
504
  catch (err) {
@@ -587,177 +507,151 @@ export class MajikKey {
587
507
  throw new MajikKeyError("Failed to import from mnemonic backup", err);
588
508
  }
589
509
  }
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) {
510
+ // ── PRIVATE: Core Derivation ─────────────────────────────────────────────────
511
+ static async _deriveAndEncryptFromMnemonic(mnemonic, passphrase) {
512
+ const encIdentity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
513
+ let exportedXPrivate;
598
514
  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);
515
+ exportedXPrivate = await crypto.subtle.exportKey("raw", encIdentity.privateKey);
516
+ }
517
+ catch {
518
+ const anyPriv = encIdentity.privateKey;
519
+ if (anyPriv?.raw instanceof Uint8Array) {
520
+ exportedXPrivate = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
605
521
  }
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
- }
522
+ else {
523
+ throw new MajikKeyError("Cannot export private key: unsupported format");
614
524
  }
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
525
  }
526
+ // Single salt — one Argon2id derivation unlocks both keys
527
+ const salt = generateRandomBytes(SALT_SIZE);
528
+ const { blob: encryptedPrivateKey } = await MajikKey._encryptPrivateKey(exportedXPrivate, passphrase, salt);
529
+ const mlKemSecretKey = encIdentity.mlKemSecretKey;
530
+ const encryptedMlKemSecretKey = await MajikKey._encryptMlKemSecretKey(mlKemSecretKey, passphrase, salt);
531
+ return {
532
+ id: encIdentity.fingerprint,
533
+ publicKey: encIdentity.publicKey,
534
+ fingerprint: encIdentity.fingerprint,
535
+ privateKey: encIdentity.privateKey,
536
+ encryptedPrivateKey,
537
+ salt: arrayToBase64(salt),
538
+ kdfVersion: KDF_VERSION.ARGON2ID,
539
+ mlKemPublicKey: encIdentity.mlKemPublicKey,
540
+ mlKemSecretKey,
541
+ encryptedMlKemSecretKey,
542
+ };
543
+ }
544
+ // ── PRIVATE: Encryption/Decryption ───────────────────────────────────────────
545
+ static async _encryptPrivateKey(buffer, passphrase, salt) {
546
+ const keyBytes = deriveKeyFromPassphraseArgon2(passphrase, salt);
547
+ const iv = generateRandomBytes(IV_LENGTH);
548
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(buffer));
549
+ return {
550
+ blob: concatUint8Arrays(iv, ciphertext).buffer,
551
+ kdfVersion: KDF_VERSION.ARGON2ID,
552
+ };
630
553
  }
631
554
  /**
632
- * Export an identity encrypted with a mnemonic-derived key.
633
- * Returns a base64 string containing iv+ciphertext and publicKey/fingerprint in JSON.
555
+ * Encrypt the ML-KEM secret key using the same Argon2id-derived key as X25519
556
+ * (same passphrase + same salt) but a DIFFERENT random IV. One Argon2id
557
+ * computation → two independently encrypted blobs.
634
558
  */
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;
559
+ static async _encryptMlKemSecretKey(mlKemSecretKey, passphrase, salt) {
560
+ const keyBytes = deriveKeyFromPassphraseArgon2(passphrase, salt);
561
+ const iv = generateRandomBytes(IV_LENGTH); // different IV from X25519 blob
562
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, mlKemSecretKey);
563
+ return concatUint8Arrays(iv, ciphertext).buffer;
564
+ }
565
+ static async _decryptPrivateKey(buffer, passphrase, salt, kdfVersion = KDF_VERSION.PBKDF2) {
566
+ const keyBytes = kdfVersion === KDF_VERSION.ARGON2ID
567
+ ? deriveKeyFromPassphraseArgon2(passphrase, salt)
568
+ : deriveKeyFromPassphrase(passphrase, salt);
569
+ const full = new Uint8Array(buffer);
570
+ const iv = full.slice(0, IV_LENGTH);
571
+ const ciphertext = full.slice(IV_LENGTH);
572
+ const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
573
+ if (!plain)
574
+ throw new MajikKeyError("Decryption failed — incorrect passphrase or corrupted data");
575
+ return plain.buffer;
576
+ }
577
+ static async _decryptMlKemSecretKey(buffer, passphrase, salt) {
578
+ // ML-KEM keys are only ever written by Argon2id (v2) code
579
+ const keyBytes = deriveKeyFromPassphraseArgon2(passphrase, salt);
580
+ const full = new Uint8Array(buffer);
581
+ const iv = full.slice(0, IV_LENGTH);
582
+ const ciphertext = full.slice(IV_LENGTH);
583
+ const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
584
+ if (!plain)
585
+ throw new MajikKeyError("Failed to decrypt ML-KEM secret key");
586
+ return plain;
587
+ }
588
+ // ── PRIVATE: Backup ──────────────────────────────────────────────────────────
589
+ static async _verifyBackupDecryption(ivBase64, ciphertextBase64, mnemonic, backupKdfVersion) {
590
+ const iv = new Uint8Array(base64ToArrayBuffer(ivBase64));
591
+ const ciphertext = base64ToArrayBuffer(ciphertextBase64);
592
+ const mnemonicSalt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
593
+ if (backupKdfVersion === KDF_VERSION.ARGON2ID) {
594
+ const keyBytes = deriveKeyFromMnemonicArgon2(mnemonic, mnemonicSalt);
595
+ const plain = aesGcmDecrypt(keyBytes, iv, new Uint8Array(ciphertext));
596
+ if (!plain)
597
+ throw new MajikKeyError("Failed to decrypt backup — invalid mnemonic or corrupted data");
598
+ }
599
+ else {
600
+ const legacyKey = await MajikKey._deriveLegacyMnemonicKey(mnemonic);
643
601
  try {
644
- privRawBuf = await crypto.subtle.exportKey("raw", identity.privateKey);
645
- pubRawBuf = await crypto.subtle.exportKey("raw", identity.publicKey);
602
+ await crypto.subtle.decrypt({ name: "AES-GCM", iv }, legacyKey, ciphertext);
646
603
  }
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
- }
604
+ catch {
605
+ throw new MajikKeyError("Failed to decrypt backup — invalid mnemonic or corrupted data");
662
606
  }
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
607
  }
680
608
  }
681
- /**
682
- * Encrypt a private key with a passphrase.
683
- */
684
- static async encryptPrivateKey(buffer, passphrase, salt) {
609
+ static async _exportMnemonicBackup(identity, mnemonic) {
610
+ if (!identity?.privateKey)
611
+ throw new MajikKeyError("Identity must have privateKey to export backup");
612
+ let privRawBuf;
613
+ let pubRawBuf;
685
614
  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;
615
+ privRawBuf = await crypto.subtle.exportKey("raw", identity.privateKey);
616
+ pubRawBuf = await crypto.subtle.exportKey("raw", identity.publicKey);
690
617
  }
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");
618
+ catch {
619
+ const anyPriv = identity.privateKey;
620
+ const anyPub = identity.publicKey;
621
+ if (anyPriv?.raw instanceof Uint8Array) {
622
+ privRawBuf = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
707
623
  }
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) {
624
+ else
625
+ throw new MajikKeyError("Cannot export private key");
626
+ if (anyPub?.raw instanceof Uint8Array) {
627
+ pubRawBuf = anyPub.raw.buffer.slice(anyPub.raw.byteOffset, anyPub.raw.byteOffset + anyPub.raw.byteLength);
628
+ }
629
+ else
630
+ throw new MajikKeyError("Cannot export public key");
631
+ }
632
+ const mnemonicSalt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
633
+ const keyBytes = deriveKeyFromMnemonicArgon2(mnemonic, mnemonicSalt);
634
+ const iv = generateRandomBytes(IV_LENGTH);
635
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(privRawBuf));
636
+ return utf8ToBase64(JSON.stringify({
637
+ id: identity.id,
638
+ iv: arrayToBase64(iv),
639
+ ciphertext: arrayToBase64(ciphertext),
640
+ publicKey: arrayBufferToBase64(pubRawBuf),
641
+ fingerprint: identity.fingerprint,
642
+ backupKdfVersion: KDF_VERSION.ARGON2ID,
643
+ }));
644
+ }
645
+ static async _deriveLegacyMnemonicKey(mnemonic) {
717
646
  const salt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
718
647
  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
- }
648
+ return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
649
+ }
650
+ static async _exportKeyToBase64(key) {
651
+ const anyKey = key;
652
+ if (anyKey?.raw instanceof Uint8Array)
653
+ return arrayBufferToBase64(anyKey.raw.buffer);
654
+ const raw = await crypto.subtle.exportKey("raw", key);
655
+ return arrayBufferToBase64(raw);
762
656
  }
763
657
  }