@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/README.md +79 -54
- package/dist/core/crypto/constants.d.ts +50 -0
- package/dist/core/crypto/constants.js +48 -0
- package/dist/core/crypto/crypto-provider.d.ts +97 -0
- package/dist/core/crypto/crypto-provider.js +112 -0
- package/dist/core/crypto/encryption-engine.d.ts +21 -3
- package/dist/core/crypto/encryption-engine.js +40 -13
- package/dist/core/database/system/identity.d.ts +1 -0
- package/dist/core/types.d.ts +12 -0
- package/dist/majik-key.d.ts +71 -171
- package/dist/majik-key.js +370 -476
- package/package.json +4 -2
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
171
|
+
const anyParsed = parsed;
|
|
215
172
|
const publicKeyBuffer = base64ToArrayBuffer(validated.publicKey);
|
|
216
173
|
const encryptedPrivateKeyBuffer = base64ToArrayBuffer(validated.encryptedPrivateKey);
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
*
|
|
287
|
-
*
|
|
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
|
-
|
|
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.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
570
|
-
publicKey,
|
|
571
|
-
publicKeyBase64
|
|
572
|
-
fingerprint:
|
|
573
|
-
encryptedPrivateKey,
|
|
574
|
-
encryptedPrivateKeyBase64: arrayBufferToBase64(encryptedPrivateKey),
|
|
575
|
-
salt:
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
*
|
|
633
|
-
*
|
|
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
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
645
|
-
pubRawBuf = await crypto.subtle.exportKey("raw", identity.publicKey);
|
|
602
|
+
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, legacyKey, ciphertext);
|
|
646
603
|
}
|
|
647
|
-
catch
|
|
648
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
|
692
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
}
|