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