@majikah/majik-key 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +566 -175
  2. package/dist/core/backup/index.d.ts +4 -4
  3. package/dist/core/backup/index.js +3 -3
  4. package/dist/core/backup/majik-key-backup.d.ts +3 -3
  5. package/dist/core/backup/majik-key-backup.js +5 -5
  6. package/dist/core/backup/types.d.ts +1 -1
  7. package/dist/core/backup/utils.js +1 -1
  8. package/dist/core/backup/validator.d.ts +1 -1
  9. package/dist/core/backup/validator.js +1 -1
  10. package/dist/core/crypto/constants.d.ts +35 -11
  11. package/dist/core/crypto/constants.js +33 -11
  12. package/dist/core/crypto/crypto-provider.js +8 -8
  13. package/dist/core/crypto/encryption-engine.d.ts +7 -16
  14. package/dist/core/crypto/encryption-engine.js +27 -62
  15. package/dist/core/database/system/identity.d.ts +2 -2
  16. package/dist/core/database/system/identity.js +2 -2
  17. package/dist/core/keys/hkdf-recipe.d.ts +4 -0
  18. package/dist/core/keys/hkdf-recipe.js +26 -0
  19. package/dist/core/keys/key-id.d.ts +50 -0
  20. package/dist/core/keys/key-id.js +64 -0
  21. package/dist/core/keys/key-impls.d.ts +12 -0
  22. package/dist/core/keys/key-impls.js +163 -0
  23. package/dist/core/keys/key-store.d.ts +72 -0
  24. package/dist/core/keys/key-store.js +264 -0
  25. package/dist/core/keys/keypair-handle.d.ts +36 -0
  26. package/dist/core/keys/keypair-handle.js +43 -0
  27. package/dist/core/keys/registry.d.ts +16 -0
  28. package/dist/core/keys/registry.js +144 -0
  29. package/dist/core/keys/types.d.ts +47 -0
  30. package/dist/core/keys/types.js +1 -0
  31. package/dist/core/types.d.ts +12 -1
  32. package/dist/core/utils.d.ts +1 -1
  33. package/dist/core/utils.js +1 -1
  34. package/dist/core/validator.d.ts +1 -1
  35. package/dist/core/validator.js +1 -1
  36. package/dist/core/web3/bitcoin/bitcoin.d.ts +1 -1
  37. package/dist/core/web3/bitcoin/bitcoin.js +4 -4
  38. package/dist/core/web3/bitcoin/types.d.ts +1 -1
  39. package/dist/core/web3/ethereum/constants.d.ts +1 -0
  40. package/dist/core/web3/ethereum/constants.js +4 -0
  41. package/dist/core/web3/ethereum/ethereum.d.ts +21 -0
  42. package/dist/core/web3/ethereum/ethereum.js +91 -0
  43. package/dist/core/web3/ethereum/types.d.ts +32 -0
  44. package/dist/core/web3/ethereum/types.js +1 -0
  45. package/dist/core/web3/index.d.ts +10 -5
  46. package/dist/core/web3/index.js +7 -2
  47. package/dist/core/web3/solana/solana.d.ts +2 -2
  48. package/dist/core/web3/solana/solana.js +6 -6
  49. package/dist/core/web3/solana/types.d.ts +1 -1
  50. package/dist/core/web3/types.d.ts +4 -2
  51. package/dist/index.d.ts +13 -6
  52. package/dist/index.js +11 -5
  53. package/dist/majik-key.d.ts +179 -281
  54. package/dist/majik-key.js +622 -726
  55. package/package.json +20 -5
package/dist/majik-key.js CHANGED
@@ -2,42 +2,43 @@
2
2
  * MajikKey.ts
3
3
  * Seed phrase account library for the Majikah ecosystem.
4
4
  *
5
+ * v0.8 — key REGISTRY. Every account stores a set of keypairs in a KeyStore,
6
+ * addressed by namespaced id (see core/keys/key-id.ts). The core four
7
+ * (classic:x25519, classic:ed25519, pq:ml-kem-768, pq:ml-dsa-87) are always
8
+ * present on new accounts; anything else is opt-in via `keys` / `addKeys()`.
9
+ *
10
+ * The pre-registry per-algorithm getters still work and are marked
11
+ * @deprecated: they are thin wrappers over the registry accessors.
12
+ *
13
+ * Derivation (all deterministic from the BIP-39 mnemonic) lives in
14
+ * core/keys/key-impls.ts. Frozen "legacy-v1" recipes are pinned by
15
+ * vectors/legacy-v1.vectors.json.
5
16
  */
6
17
  import { generateMnemonic as bip39GenerateMnemonic, mnemonicToSeed, validateMnemonic, } from "@scure/bip39";
7
- import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromPassphraseArgon2, deriveKeyFromMnemonicArgon2, deriveKeyFromPassphrase, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider";
8
- import { EncryptionEngine } from "./core/crypto/encryption-engine";
9
- import { MajikContact, } from "@majikah/majik-contact";
10
- import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, concatUint8Arrays, utf8ToBase64, base64ToUtf8, seedStringToArray, seedArrayToString, base64ToUint8Array, } from "./core/utils";
11
- import { KDF_VERSION, MAJIK_MNEMONIC_SALT } from "./core/crypto/constants";
12
- import { MajikKeyValidator } from "./core/validator";
13
- import { MajikKeyError } from "./core/error";
14
- import { MajikMessageIdentity } from "./core/database/system/identity";
15
- import { WORDLISTS } from "./core/crypto/wordlist";
16
- import { deriveBitcoinKeypairFromSeed, signWithBitcoinMaterial, toBitcoinAddress, toWIF, deriveSolanaKeypairFromEdSecretKey, signWithSolanaMaterial, solanaAddressFromPublicKey, solanaMaterialFromEd25519SecretKey, toSolanaAddress, toSolanaKeyPairSigner, } from "./core/web3";
18
+ import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromPassphraseArgon2, deriveKeyFromMnemonicArgon2, deriveKeyFromPassphrase, fingerprintFromPublicRaw, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider.js";
19
+ import { MajikContact } from "@majikah/majik-contact/dist/contacts/majik-contact.js";
20
+ import { arrayToBase64, base64ToArrayBuffer, utf8ToBase64, base64ToUtf8, seedStringToArray, seedArrayToString, base64ToUint8Array, } from "./core/utils.js";
21
+ import { KDF_VERSION, LEGACY_MAJIK_MNEMONIC_SALT, BACKUP_SALT_WRITE_VERSION, backupSaltFor, } from "./core/crypto/constants.js";
22
+ import { MajikKeyValidator } from "./core/validator.js";
23
+ import { MajikKeyError } from "./core/error.js";
24
+ import { MajikMessageIdentity } from "./core/database/system/identity.js";
25
+ import { WORDLISTS } from "./core/crypto/wordlist.js";
26
+ import { deriveBitcoinKeypairFromSeed, signWithBitcoinMaterial, toBitcoinAddress, toWIF, deriveSolanaKeypairFromEdSecretKey, signWithSolanaMaterial, solanaAddressFromPublicKey, solanaMaterialFromEd25519SecretKey, toSolanaAddress, toSolanaKeyPairSigner, ethereumAddressFromPublicKey, signEthereumHash, signEthereumMessage, toEthereumPrivateKeyHex, } from "./core/web3/index.js";
27
+ import { CORE_KEYS, KeyId } from "./core/keys/key-id.js";
28
+ import { KEY_ALGORITHMS, enableableKeyIds, getAlgorithm, knownKeyIds, resolveRequestedKeys, } from "./core/keys/registry.js";
29
+ import { deriveKeys } from "./core/keys/key-impls.js";
30
+ import { KeyStore } from "./core/keys/key-store.js";
31
+ import { MajikKeypair } from "./core/keys/keypair-handle.js";
32
+ export { KeyId, KeyFamily, CORE_KEYS } from "./core/keys/key-id.js";
33
+ export { MajikKeypair } from "./core/keys/keypair-handle.js";
17
34
  const secureFill = Uint8Array.prototype.fill;
18
35
  const SALT_SIZE = 32;
36
+ const KEYS_VERSION = 1;
19
37
  /**
20
38
  * MajikKey
21
39
  * ---
22
- *
23
- * Seed phrase account library for the Majikah ecosystem.
24
- *
25
- * Every account stores FIVE keypairs, all deterministically derived from a
26
- * single BIP-39 mnemonic:
27
- * 1. X25519 (Curve25519) — fingerprint, contact identity, legacy message compat
28
- * 2. ML-KEM-768 (FIPS-203) — post-quantum key encapsulation for v3 envelopes
29
- * 3. Ed25519 — classical signing
30
- * 4. ML-DSA-87 (FIPS-204) — post-quantum signing
31
- * 5. Bitcoin (secp256k1) — BIP-32/84 HD key, domain-separated by default (experimental)
32
- *
33
- * All derived from the 64-byte BIP-39 seed:
34
- * seed[0..32] → Ed25519 keypair — used directly for signing, AND converted
35
- * to X25519 via ed2curve for the encryption/identity keypair
36
- * (one Ed25519 keypair, two roles)
37
- * seed[0..64] → ml_kem768.keygen(seed) — full seed, deterministic
38
- * hash(seed || "MajikSignatureSeedDSA") → 32-byte seed → ml_dsa87.keygen()
39
- * seed[0..64] → HDKey.fromMasterSeed(seed).derive(path) — BIP-32/84 Bitcoin key
40
- *
40
+ * Registry of keypairs deterministically derived from one BIP-39 mnemonic.
41
+ * See core/keys/registry.ts for every supported algorithm and its status.
41
42
  */
42
43
  export class MajikKey {
43
44
  _id;
@@ -47,81 +48,30 @@ export class MajikKey {
47
48
  _backup;
48
49
  _timestamp;
49
50
  _mnemonicLanguage;
50
- _encryptedPrivateKey;
51
- _encryptedPrivateKeyBase64;
51
+ _store;
52
52
  _salt;
53
53
  _label;
54
54
  _kdfVersion;
55
- _mlKemPublicKey;
56
- _mlKemSecretKey;
57
- _encryptedMlKemSecretKey;
58
- _encryptedMlKemSecretKeyBase64;
59
- _privateKey;
60
- _edPublicKey;
61
- _edSecretKey;
62
- _encryptedEdSecretKey;
63
- _encryptedEdSecretKeyBase64;
64
- _mlDsaPublicKey;
65
- _mlDsaSecretKey;
66
- _encryptedMlDsaSecretKey;
67
- _encryptedMlDsaSecretKeyBase64;
68
- /**
69
- * @experimental
70
- */
55
+ /** @experimental derived view over classic:ed25519; cached while unlocked */
71
56
  _solanaKeypairMaterial;
72
- /**
73
- * @experimental
74
- */
75
- _btcPublicKey;
76
- /**
77
- * @experimental
78
- */
79
- _btcSecretKey;
80
- /**
81
- * @experimental
82
- */
83
- _encryptedBtcSecretKey;
84
- /**
85
- * @experimental
86
- */
87
- _encryptedBtcSecretKeyBase64;
88
- constructor(options) {
89
- this._id = options.id;
90
- this._publicKey = options.publicKey;
91
- this._publicKeyBase64 = options.publicKeyBase64;
92
- this._fingerprint = options.fingerprint;
93
- this._encryptedPrivateKey = options.encryptedPrivateKey;
94
- this._encryptedPrivateKeyBase64 = options.encryptedPrivateKeyBase64;
95
- this._salt = options.salt;
96
- this._backup = options.backup;
97
- this._label = options.label || "";
98
- this._timestamp = options.timestamp || new Date();
99
- this._kdfVersion = options.kdfVersion ?? KDF_VERSION.PBKDF2;
100
- this._mlKemPublicKey = options.mlKemPublicKey;
101
- this._mlKemSecretKey = options.mlKemSecretKey;
102
- this._encryptedMlKemSecretKey = options.encryptedMlKemSecretKey;
103
- this._encryptedMlKemSecretKeyBase64 = options.encryptedMlKemSecretKeyBase64;
104
- this._privateKey = options.privateKey;
105
- this._edPublicKey = options.edPublicKey;
106
- this._encryptedEdSecretKey = options.encryptedEdSecretKey;
107
- this._encryptedEdSecretKeyBase64 = options.encryptedEdSecretKeyBase64;
108
- this._mlDsaPublicKey = options.mlDsaPublicKey;
109
- this._encryptedMlDsaSecretKey = options.encryptedMlDsaSecretKey;
110
- this._encryptedMlDsaSecretKeyBase64 = options.encryptedMlDsaSecretKeyBase64;
111
- this._edSecretKey = options.edSecretKey;
112
- this._mlDsaSecretKey = options.mlDsaSecretKey;
113
- this._btcPublicKey = options.btcPublicKey;
114
- this._btcSecretKey = options.btcSecretKey;
115
- this._encryptedBtcSecretKey = options.encryptedBtcSecretKey;
116
- this._encryptedBtcSecretKeyBase64 = options.encryptedBtcSecretKeyBase64;
117
- this._mnemonicLanguage = options.mnemonicLanguage || "en";
57
+ constructor(init) {
58
+ this._id = init.id;
59
+ this._store = init.store;
60
+ const xPub = init.store.getPublicKey(KeyId.X25519);
61
+ this._publicKey = { raw: xPub };
62
+ this._publicKeyBase64 = arrayToBase64(xPub);
63
+ this._fingerprint = init.fingerprint;
64
+ this._salt = init.salt;
65
+ this._backup = init.backup;
66
+ this._label = init.label || "";
67
+ this._timestamp = init.timestamp || new Date();
68
+ this._kdfVersion = init.kdfVersion ?? KDF_VERSION.PBKDF2;
69
+ this._mnemonicLanguage = init.mnemonicLanguage || "en";
118
70
  }
119
71
  // ── Getters ─────────────────────────────────────────────────────────────────
120
- /** Account identifier. Equal to `fingerprint` for accounts created by this library. */
121
72
  get id() {
122
73
  return this._id;
123
74
  }
124
- /** SHA-256 fingerprint of the X25519 public key. Stable identity anchor for the account. */
125
75
  get fingerprint() {
126
76
  return this._fingerprint;
127
77
  }
@@ -129,80 +79,186 @@ export class MajikKey {
129
79
  get publicKey() {
130
80
  return this._publicKey;
131
81
  }
132
- /** X25519 public key, base64-encoded. Always available, even when locked. */
133
82
  get publicKeyBase64() {
134
83
  return this._publicKeyBase64;
135
84
  }
136
- /** Human-readable, user-editable account name. Update via `updateLabel()`. */
137
85
  get label() {
138
86
  return this._label;
139
87
  }
140
- /** BIP-39 wordlist language this account's mnemonic was generated/validated against. */
141
88
  get mnemonicLanguage() {
142
89
  return this._mnemonicLanguage;
143
90
  }
144
- /**
145
- * Encrypted mnemonic-verification blob (base64 JSON). Decryptable only
146
- * with the original mnemonic — used internally to verify a supplied
147
- * mnemonic before `importFromMnemonicBackup()` re-derives the full
148
- * identity. Not a general-purpose private-key backup.
149
- */
150
91
  get backup() {
151
92
  return this._backup;
152
93
  }
153
- /** Account creation time. */
154
94
  get timestamp() {
155
95
  return this._timestamp;
156
96
  }
157
- /** KDF currently protecting every `encrypted*` field on this account: `1` = legacy PBKDF2, `2` = Argon2id. */
158
97
  get kdfVersion() {
159
98
  return this._kdfVersion;
160
99
  }
161
- /** `true` if this account is on the current KDF (Argon2id). `false` means it's still on legacy PBKDF2 — see `migrate()` or `importFromMnemonicBackup()`. */
162
100
  get isArgon2id() {
163
101
  return this._kdfVersion === KDF_VERSION.ARGON2ID;
164
102
  }
165
- /** `true` if private key material is currently purged from memory (i.e. `lock()` was called, or `unlock()` hasn't been called yet). */
166
103
  get isLocked() {
167
- return this._privateKey === undefined;
104
+ return !this._store.isUnlocked;
168
105
  }
169
- /** `true` if private key material is currently decrypted in memory. The inverse of `isLocked`. */
170
106
  get isUnlocked() {
171
- return this._privateKey !== undefined;
107
+ return this._store.isUnlocked;
172
108
  }
173
- /** ML-KEM-768 (FIPS-203) public key. Post-quantum key encapsulation. Always available, even when locked. */
109
+ /** `true` if this account holds every key in CORE_KEYS. Legacy accounts may not — see `missingKeys()` / `addKeys()`. */
110
+ get isCoreComplete() {
111
+ return this._store.hasAll(CORE_KEYS);
112
+ }
113
+ /** `true` if this account is on Argon2id *and* has ML-KEM-768 keys. */
114
+ get isFullyUpgraded() {
115
+ return this.isArgon2id && this.hasMlKem;
116
+ }
117
+ // ── Registry accessors ──────────────────────────────────────────────────────
118
+ /** Is this key present on the account? Works while locked. Derived views (web3:sol) count when their source key exists. */
119
+ hasKey(id) {
120
+ if (this._store.has(id))
121
+ return true;
122
+ const def = getAlgorithm(id);
123
+ return (!!def &&
124
+ def.kind === "derived" &&
125
+ !!def.derivedFrom &&
126
+ this._store.has(def.derivedFrom));
127
+ }
128
+ hasKeys(ids) {
129
+ return ids.every((id) => this.hasKey(id));
130
+ }
131
+ /** Which of `ids` (default: the core four) are NOT on this account. */
132
+ missingKeys(ids = CORE_KEYS) {
133
+ return ids.filter((id) => !this.hasKey(id));
134
+ }
135
+ /** Namespaced ids of every key available on this account, in canonical order. */
136
+ availableKeys(options) {
137
+ return knownKeyIds(options?.family).filter((id) => this.hasKey(id));
138
+ }
139
+ /** Metadata for every available key. No secret material. */
140
+ listKeys() {
141
+ return this.availableKeys().map((id) => {
142
+ const def = KEY_ALGORITHMS[id];
143
+ let pub;
144
+ try {
145
+ pub = arrayToBase64(this.getPublicKey(id));
146
+ }
147
+ catch {
148
+ pub = undefined; // derived view while locked
149
+ }
150
+ return {
151
+ id,
152
+ family: def.family,
153
+ purpose: def.purpose,
154
+ kind: def.kind,
155
+ status: def.status,
156
+ publicKeyBase64: pub,
157
+ };
158
+ });
159
+ }
160
+ /** Every algorithm id this library version can create/enable. */
161
+ static supportedKeys() {
162
+ return enableableKeyIds();
163
+ }
164
+ /** Public key bytes for `id`. Works while locked (derived views need an unlocked account). */
165
+ getPublicKey(id) {
166
+ if (this._store.has(id))
167
+ return this._store.getPublicKey(id);
168
+ if (id === KeyId.SOL && this._store.has(KeyId.ED25519))
169
+ return this.getSolanaKeypairMaterial().publicKey;
170
+ throw new MajikKeyError(`No "${id}" key on this account`);
171
+ }
172
+ getPrivateKey(id) {
173
+ if (id === undefined)
174
+ return { raw: this._requireSecret(KeyId.X25519) };
175
+ if (id === KeyId.SOL && this._store.has(KeyId.ED25519))
176
+ return this.getSolanaKeypairMaterial().secretKey;
177
+ return this._requireSecret(id);
178
+ }
179
+ /** A live handle with `.public` / `.private` / `.publicBase64`. Reads through to the account, so it never goes stale across lock(). */
180
+ getKeypair(id) {
181
+ if (!this.hasKey(id))
182
+ throw new MajikKeyError(`No "${id}" key on this account`);
183
+ return new MajikKeypair(id, () => this.getPublicKey(id), () => this.getPrivateKey(id), () => this.isUnlocked);
184
+ }
185
+ _requireSecret(id, missingMessage) {
186
+ if (this.isLocked) {
187
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
188
+ }
189
+ const slot = this._store.slot(id);
190
+ if (!slot) {
191
+ throw new MajikKeyError(missingMessage ??
192
+ `No "${id}" key on this account — add it with addKeys(), which requires the mnemonic.`);
193
+ }
194
+ const secret = this._store.peekSecretKey(id);
195
+ if (!secret) {
196
+ if (id === KeyId.BTC) {
197
+ throw new MajikKeyError("Bitcoin private key material is unavailable; re-import via importFromMnemonicBackup.");
198
+ }
199
+ throw new MajikKeyError(missingMessage ??
200
+ `Private key material for "${id}" is unavailable. Re-import the account from its mnemonic backup.`);
201
+ }
202
+ return secret;
203
+ }
204
+ // ── Deprecated per-algorithm getters (wrappers over the registry) ───────────
205
+ /** @deprecated Use `getPublicKey(KeyId.ML_KEM_768)`. */
174
206
  get mlKemPublicKey() {
175
- return this._mlKemPublicKey;
207
+ return (this._store.has(KeyId.ML_KEM_768)
208
+ ? this._store.getPublicKey(KeyId.ML_KEM_768)
209
+ : undefined);
176
210
  }
177
- /** ML-KEM-768 secret key. `undefined` unless the account is unlocked. ⚠️ Live key material — prefer `getMlKemSecretKey()` if you want a thrown error instead of `undefined` on locked accounts. */
211
+ /** @deprecated Use `getPrivateKey(KeyId.ML_KEM_768)`. */
178
212
  get mlKemSecretKey() {
179
- return this._mlKemSecretKey;
213
+ return this._store.peekSecretKey(KeyId.ML_KEM_768);
180
214
  }
181
- /** `true` if this account has ML-KEM-768 keys (i.e. is post-quantum-encryption capable). `false` means it's a legacy account pending migration. */
215
+ /** @deprecated Use `hasKey(KeyId.ML_KEM_768)`. */
182
216
  get hasMlKem() {
183
- return this._mlKemPublicKey !== undefined;
217
+ return this._store.has(KeyId.ML_KEM_768);
184
218
  }
185
- /** `true` if this account is on Argon2id *and* has ML-KEM-768 keys — i.e. fully migrated, nothing left to upgrade. */
186
- get isFullyUpgraded() {
187
- return this.isArgon2id && this.hasMlKem;
219
+ /** @deprecated Use `getPublicKey(KeyId.ED25519)`. */
220
+ get edPublicKey() {
221
+ return this._store.has(KeyId.ED25519)
222
+ ? this._store.getPublicKey(KeyId.ED25519)
223
+ : undefined;
188
224
  }
189
- /**
190
- * @experimental secp256k1 Bitcoin public key. `undefined` if this account
191
- * has no stored Bitcoin key material (e.g. it predates Web3 support and
192
- * hasn't been re-imported via `importFromMnemonicBackup()`).
193
- */
225
+ /** @deprecated Use `getPublicKey(KeyId.ML_DSA_87)`. */
226
+ get mlDsaPublicKey() {
227
+ return this._store.has(KeyId.ML_DSA_87)
228
+ ? this._store.getPublicKey(KeyId.ML_DSA_87)
229
+ : undefined;
230
+ }
231
+ /** @deprecated Use `hasKeys([KeyId.ED25519, KeyId.ML_DSA_87])`. */
232
+ get hasSigningKeys() {
233
+ return this._store.has(KeyId.ED25519) && this._store.has(KeyId.ML_DSA_87);
234
+ }
235
+ /** @experimental @deprecated Use `getPublicKey(KeyId.BTC)`. */
194
236
  get btcPublicKey() {
195
- return this._btcPublicKey;
237
+ return this._store.has(KeyId.BTC)
238
+ ? this._store.getPublicKey(KeyId.BTC)
239
+ : undefined;
196
240
  }
197
- /** @experimental `true` if this account has a stored Bitcoin keypair. */
241
+ /** @experimental @deprecated Use `hasKey(KeyId.BTC)`. */
198
242
  get hasBitcoin() {
199
- return this._btcPublicKey !== undefined;
243
+ return this._store.has(KeyId.BTC);
200
244
  }
201
- /**
202
- * Lightweight, non-secret snapshot of this account's state — no key bytes
203
- * at all, encrypted or otherwise. Useful for account pickers, dashboards,
204
- * or anywhere you want to display status without touching key material.
205
- */
245
+ /** @deprecated Use `getPrivateKey(KeyId.ML_KEM_768)`. */
246
+ getMlKemSecretKey() {
247
+ return this._requireSecret(KeyId.ML_KEM_768, "No ML-KEM secret key — add it with addKeys() (requires the mnemonic).");
248
+ }
249
+ /** @deprecated Use `getPrivateKey(KeyId.ED25519)`. */
250
+ getEdSecretKey() {
251
+ return this._requireSecret(KeyId.ED25519, "No Ed25519 secret key — add it with addKeys() (requires the mnemonic).");
252
+ }
253
+ /** @deprecated Use `getPrivateKey(KeyId.ML_DSA_87)`. */
254
+ getMlDsaSecretKey() {
255
+ return this._requireSecret(KeyId.ML_DSA_87, "No ML-DSA secret key — add it with addKeys() (requires the mnemonic).");
256
+ }
257
+ /** @experimental @deprecated Use `getPrivateKey(KeyId.BTC)`. */
258
+ getBtcSecretKey() {
259
+ return this._requireSecret(KeyId.BTC, "No Bitcoin secret key — add it with addKeys([KeyId.BTC], mnemonic, passphrase).");
260
+ }
261
+ /** Non-secret snapshot of this account's state. */
206
262
  get metadata() {
207
263
  return {
208
264
  id: this.id,
@@ -213,91 +269,53 @@ export class MajikKey {
213
269
  kdfVersion: this.kdfVersion,
214
270
  hasMlKem: this.hasMlKem,
215
271
  web3: {
272
+ hasEthereum: this.hasEthereum,
216
273
  hasBitcoin: this.hasBitcoin,
217
274
  hasSolana: this.hasSolanaKeypair,
218
275
  },
276
+ keys: this.availableKeys(),
219
277
  mnemonicLanguage: this.mnemonicLanguage || "en",
220
278
  };
221
279
  }
222
- /** Ed25519 public key. Classical signing — same keypair the X25519 identity key is converted from. Always available, even when locked. */
223
- get edPublicKey() {
224
- return this._edPublicKey;
225
- }
226
- /** ML-DSA-87 (FIPS-204) public key. Post-quantum signing. Always available, even when locked. */
227
- get mlDsaPublicKey() {
228
- return this._mlDsaPublicKey;
229
- }
230
- /** `true` if this account has both Ed25519 and ML-DSA-87 signing keys. `false` means it's a legacy account pending migration. */
231
- get hasSigningKeys() {
232
- return (this._edPublicKey !== undefined && this._mlDsaPublicKey !== undefined);
233
- }
234
280
  // ── CREATE ──────────────────────────────────────────────────────────────────
235
281
  /**
236
- * Creates a brand-new MajikKey account from a BIP-39 mnemonic.
282
+ * Creates a brand-new MajikKey from a BIP-39 mnemonic and returns it UNLOCKED.
283
+ *
284
+ * Always derives the core four (X25519, Ed25519, ML-KEM-768, ML-DSA-87).
285
+ * Pass `options.keys` for more, e.g. `{ keys: [KeyId.BTC] }`.
237
286
  *
238
- * Derives the full key set in one pass — X25519, ML-KEM-768, Ed25519,
239
- * ML-DSA-87, and a domain-separated Bitcoin key (see `MAJIK_BITCOIN_DOMAIN_PATH`)
240
- * — encrypts every private key with Argon2id (KDF v2), and returns an
241
- * **already-unlocked** instance (no `unlock()` call needed right after
242
- * `create()`).
287
+ * ⚠️ Behavior change vs 0.7: Bitcoin is no longer derived by default.
243
288
  *
244
- * @param mnemonic - A valid BIP-39 mnemonic phrase (12 or 24 words), matching `mnemonicLanguage`. Generate one with `MajikKey.generateMnemonic()`.
245
- * @param passphrase - Passphrase used to derive the Argon2id encryption key for every private key on this account. This is *not* the mnemonic — losing it without the mnemonic makes the account unrecoverable.
246
- * @param label - Optional human-readable account name. Defaults to an empty string. Update later via `updateLabel()`.
247
- * @param mnemonicLanguage - BIP-39 wordlist to validate `mnemonic` against. Defaults to `"en"`.
248
- * @param options.deriveBitcoin - @experimental Set `false` to skip deriving the Bitcoin keypair. Defaults to `true`.
249
- * @returns An unlocked `MajikKey` instance, ready for immediate use — call `.lock()` when you're done with it.
250
- * @throws {MajikKeyError} If `mnemonic` fails validation, `passphrase`/`label` fail their validators, or `mnemonic` doesn't match `mnemonicLanguage`'s wordlist.
289
+ * @throws {MajikKeyError} on invalid mnemonic/passphrase/label or unusable key ids.
251
290
  */
252
- static async create(mnemonic, passphrase, label, options = {
253
- deriveBitcoin: true,
254
- mnemonicLanguage: "en",
255
- }) {
291
+ static async create(mnemonic, passphrase, label, options = {}) {
256
292
  try {
257
293
  MajikKeyValidator.validateMnemonic(mnemonic);
258
294
  MajikKeyValidator.validatePassphrase(passphrase);
259
295
  MajikKeyValidator.validateLabel(label);
260
- const { deriveBitcoin, mnemonicLanguage } = options;
261
- const wordlist = await MajikKey._getWordlist(mnemonicLanguage || "en");
296
+ const mnemonicLanguage = options.mnemonicLanguage || "en";
297
+ const ids = MajikKey._resolveCreateKeys(options);
298
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
262
299
  if (!validateMnemonic(mnemonic, wordlist)) {
263
300
  throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
264
301
  }
265
- const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase, { deriveBitcoin: deriveBitcoin });
266
- const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
267
- const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
268
- const backup = await MajikKey._exportMnemonicBackup(identity, mnemonic);
302
+ const d = await MajikKey._deriveFromMnemonic(mnemonic, passphrase, ids);
303
+ const backup = await MajikKey._exportMnemonicBackup({
304
+ id: d.fingerprint,
305
+ fingerprint: d.fingerprint,
306
+ publicRaw: d.xPublic,
307
+ privateRaw: d.xSecret,
308
+ }, mnemonic);
269
309
  return new MajikKey({
270
- id: identity.id,
271
- publicKey: identity.publicKey,
272
- publicKeyBase64,
273
- fingerprint: identity.fingerprint,
274
- encryptedPrivateKey: identity.encryptedPrivateKey,
275
- encryptedPrivateKeyBase64: arrayBufferToBase64(identity.encryptedPrivateKey),
276
- salt: identity.salt,
310
+ id: d.fingerprint,
311
+ fingerprint: d.fingerprint,
312
+ salt: d.salt,
277
313
  backup,
278
314
  label: label || "",
279
315
  timestamp: new Date(),
280
316
  kdfVersion: KDF_VERSION.ARGON2ID,
281
- mlKemPublicKey: identity.mlKemPublicKey,
282
- mlKemSecretKey: identity.mlKemSecretKey,
283
- encryptedMlKemSecretKey: identity.encryptedMlKemSecretKey,
284
- encryptedMlKemSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlKemSecretKey),
285
- privateKey: identity.privateKey,
286
- edPublicKey: identity.edPublicKey,
287
- encryptedEdSecretKey: identity.encryptedEdSecretKey,
288
- encryptedEdSecretKeyBase64: arrayBufferToBase64(identity.encryptedEdSecretKey),
289
- mlDsaPublicKey: identity.mlDsaPublicKey,
290
- encryptedMlDsaSecretKey: identity.encryptedMlDsaSecretKey,
291
- encryptedMlDsaSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlDsaSecretKey),
292
- edSecretKey: identity.edSecretKey,
293
- mlDsaSecretKey: identity.mlDsaSecretKey,
294
- btcPublicKey: identity.btcPublicKey,
295
- encryptedBtcSecretKey: identity.encryptedBtcSecretKey,
296
- encryptedBtcSecretKeyBase64: identity.encryptedBtcSecretKey
297
- ? arrayBufferToBase64(identity.encryptedBtcSecretKey)
298
- : undefined,
299
- btcSecretKey: identity.btcSecretKey,
300
- mnemonicLanguage: mnemonicLanguage,
317
+ mnemonicLanguage,
318
+ store: d.store,
301
319
  });
302
320
  }
303
321
  catch (err) {
@@ -306,74 +324,56 @@ export class MajikKey {
306
324
  throw new MajikKeyError("Failed to create MajikKey", err);
307
325
  }
308
326
  }
327
+ static _resolveCreateKeys(options) {
328
+ const requested = [...(options.keys ?? [])];
329
+ if (options.deriveBitcoin === true)
330
+ requested.push(KeyId.BTC);
331
+ return resolveRequestedKeys(requested);
332
+ }
309
333
  // ── READ ────────────────────────────────────────────────────────────────────
334
+ /**
335
+ * Parse a MajikKey from JSON. Accepts BOTH shapes:
336
+ * - registry JSON (has `keys`) → used as-is
337
+ * - legacy flat JSON (no `keys`) → auto-migrated in memory (no secrets, no
338
+ * passphrase, no mnemonic needed). Re-serialize with toJSON() to persist
339
+ * the upgraded shape.
340
+ */
310
341
  static fromJSON(json) {
311
342
  try {
312
343
  const parsed = typeof json === "string" ? JSON.parse(json) : json;
313
- const validated = MajikKeyValidator.validateJSON(parsed);
314
344
  const anyParsed = parsed;
315
- const publicKeyBuffer = base64ToArrayBuffer(validated.publicKey);
316
- const encryptedPrivateKeyBuffer = base64ToArrayBuffer(validated.encryptedPrivateKey);
317
- const mlKemPublicKey = base64ToUint8Array(anyParsed.mlKemPublicKey);
318
- let encryptedMlKemSecretKey;
319
- let encryptedMlKemSecretKeyBase64;
320
- if (anyParsed.encryptedMlKemSecretKey) {
321
- encryptedMlKemSecretKeyBase64 = anyParsed.encryptedMlKemSecretKey;
322
- encryptedMlKemSecretKey = base64ToArrayBuffer(anyParsed.encryptedMlKemSecretKey);
323
- }
324
- const edPublicKey = anyParsed.edPublicKey
325
- ? base64ToUint8Array(anyParsed.edPublicKey)
326
- : undefined;
327
- let encryptedEdSecretKey;
328
- let encryptedEdSecretKeyBase64;
329
- if (anyParsed.encryptedEdSecretKey) {
330
- encryptedEdSecretKeyBase64 = anyParsed.encryptedEdSecretKey;
331
- encryptedEdSecretKey = base64ToArrayBuffer(anyParsed.encryptedEdSecretKey);
345
+ let store;
346
+ let base;
347
+ if (Array.isArray(anyParsed.keys)) {
348
+ base = MajikKey._validateRegistryJSON(anyParsed);
349
+ store = KeyStore.fromEntries(anyParsed.keys);
350
+ if (!store.has(KeyId.X25519))
351
+ throw new MajikKeyError("Invalid MajikKey JSON: `keys` has no classic:x25519 entry");
352
+ // If the flat legacy field is also present it must agree (corruption/tamper check).
353
+ if (anyParsed.publicKey &&
354
+ anyParsed.publicKey !==
355
+ arrayToBase64(store.getPublicKey(KeyId.X25519)))
356
+ throw new MajikKeyError("Invalid MajikKey JSON: `publicKey` does not match the classic:x25519 entry");
332
357
  }
333
- const mlDsaPublicKey = anyParsed.mlDsaPublicKey
334
- ? base64ToUint8Array(anyParsed.mlDsaPublicKey)
335
- : undefined;
336
- let encryptedMlDsaSecretKey;
337
- let encryptedMlDsaSecretKeyBase64;
338
- if (anyParsed.encryptedMlDsaSecretKey) {
339
- encryptedMlDsaSecretKeyBase64 = anyParsed.encryptedMlDsaSecretKey;
340
- encryptedMlDsaSecretKey = base64ToArrayBuffer(anyParsed.encryptedMlDsaSecretKey);
341
- }
342
- const btcPublicKey = anyParsed.btcPublicKey
343
- ? base64ToUint8Array(anyParsed.btcPublicKey)
344
- : undefined;
345
- let encryptedBtcSecretKey;
346
- let encryptedBtcSecretKeyBase64;
347
- if (anyParsed.encryptedBtcSecretKey) {
348
- encryptedBtcSecretKeyBase64 = anyParsed.encryptedBtcSecretKey;
349
- encryptedBtcSecretKey = base64ToArrayBuffer(anyParsed.encryptedBtcSecretKey);
358
+ else {
359
+ const validated = MajikKeyValidator.validateJSON(parsed);
360
+ base = validated;
361
+ store = KeyStore.fromLegacyJSON({
362
+ ...anyParsed,
363
+ publicKey: validated.publicKey,
364
+ encryptedPrivateKey: validated.encryptedPrivateKey,
365
+ });
350
366
  }
351
367
  return new MajikKey({
352
- id: validated.id,
353
- publicKey: { raw: new Uint8Array(publicKeyBuffer) },
354
- publicKeyBase64: validated.publicKey,
355
- fingerprint: validated.fingerprint,
356
- encryptedPrivateKey: encryptedPrivateKeyBuffer,
357
- encryptedPrivateKeyBase64: validated.encryptedPrivateKey,
358
- salt: validated.salt,
359
- backup: validated.backup,
360
- label: validated.label || "",
361
- timestamp: new Date(validated.timestamp),
362
- kdfVersion: validated.kdfVersion ??
363
- KDF_VERSION.PBKDF2,
364
- mlKemPublicKey,
365
- encryptedMlKemSecretKey,
366
- encryptedMlKemSecretKeyBase64,
367
- edPublicKey,
368
- encryptedEdSecretKey,
369
- encryptedEdSecretKeyBase64,
370
- mlDsaPublicKey,
371
- encryptedMlDsaSecretKey,
372
- encryptedMlDsaSecretKeyBase64,
373
- btcPublicKey,
374
- encryptedBtcSecretKey,
375
- encryptedBtcSecretKeyBase64,
376
- mnemonicLanguage: validated?.mnemonicLanguage || "en",
368
+ id: base.id,
369
+ fingerprint: base.fingerprint,
370
+ salt: base.salt,
371
+ backup: base.backup,
372
+ label: base.label || "",
373
+ timestamp: new Date(base.timestamp),
374
+ kdfVersion: base.kdfVersion ?? KDF_VERSION.PBKDF2,
375
+ mnemonicLanguage: base.mnemonicLanguage || "en",
376
+ store,
377
377
  });
378
378
  }
379
379
  catch (err) {
@@ -382,35 +382,44 @@ export class MajikKey {
382
382
  throw new MajikKeyError("Failed to parse MajikKey from JSON", err);
383
383
  }
384
384
  }
385
+ static _validateRegistryJSON(j) {
386
+ for (const f of ["id", "fingerprint", "salt", "backup", "timestamp"]) {
387
+ if (typeof j[f] !== "string" || !j[f])
388
+ throw new MajikKeyError(`Invalid MajikKey JSON: missing "${f}"`);
389
+ }
390
+ if (j.keysVersion !== undefined && j.keysVersion > KEYS_VERSION)
391
+ throw new MajikKeyError(`This MajikKey JSON uses keys schema v${j.keysVersion}; this library supports up to v${KEYS_VERSION}. Upgrade the library.`);
392
+ return j;
393
+ }
385
394
  /**
386
395
  * Export a fully unlocked MajikKey with all raw private keys.
387
396
  * ⚠️ DANGEROUS — output contains unencrypted private key material.
388
397
  * Only use for server-side secrets injection.
389
- * Never log, store in a database, or transmit over the network.
390
398
  */
391
399
  toDangerousJSON() {
392
400
  if (this.isLocked)
393
401
  throw new MajikKeyError("MajikKey must be unlocked to export dangerous JSON.");
394
- if (!this._edSecretKey ||
395
- !this._mlDsaSecretKey ||
396
- !this._mlKemSecretKey ||
397
- !this._privateKey)
398
- throw new MajikKeyError("MajikKey is missing secret keys — re-import via importFromMnemonicBackup() first.");
402
+ if (!this.hasKeys(CORE_KEYS))
403
+ throw new MajikKeyError("MajikKey is missing core keys — add them with addKeys(CORE_KEYS, mnemonic, passphrase) first.");
404
+ const secretKeys = {};
405
+ for (const [id, secret] of this._store.exportSecrets())
406
+ secretKeys[id] = arrayToBase64(secret);
407
+ const s = (id) => this._store.getSecretKey(id);
399
408
  return {
400
409
  ...this.toJSON(),
401
- privateKeyBase64: arrayToBase64(this._privateKey.raw),
402
- mlKemSecretKeyBase64: arrayToBase64(this._mlKemSecretKey),
403
- edSecretKeyBase64: arrayToBase64(this._edSecretKey),
404
- mlDsaSecretKeyBase64: arrayToBase64(this._mlDsaSecretKey),
405
- btcSecretKeyBase64: this._btcSecretKey
406
- ? arrayToBase64(this._btcSecretKey)
410
+ privateKeyBase64: arrayToBase64(s(KeyId.X25519)),
411
+ mlKemSecretKeyBase64: arrayToBase64(s(KeyId.ML_KEM_768)),
412
+ edSecretKeyBase64: arrayToBase64(s(KeyId.ED25519)),
413
+ mlDsaSecretKeyBase64: arrayToBase64(s(KeyId.ML_DSA_87)),
414
+ btcSecretKeyBase64: this._store.has(KeyId.BTC)
415
+ ? arrayToBase64(s(KeyId.BTC))
407
416
  : undefined,
417
+ secretKeys,
408
418
  };
409
419
  }
410
420
  /**
411
421
  * Reconstruct a fully unlocked MajikKey from a dangerous JSON export.
412
422
  * ⚠️ DANGEROUS — input contains unencrypted private key material.
413
- * Intended for server-side use only (e.g. TSA signing key loaded from Cloudflare Secrets).
414
423
  * No KDF is involved — reconstruction is instant.
415
424
  */
416
425
  static fromDangerousJSON(json) {
@@ -427,38 +436,34 @@ export class MajikKey {
427
436
  !parsed.mlKemPublicKey ||
428
437
  !parsed.mlKemSecretKeyBase64)
429
438
  throw new MajikKeyError("Invalid MajikKeyDangerousJSON — missing required fields");
430
- const privateKeyBytes = base64ToUint8Array(parsed.privateKeyBase64);
431
- const edPublicKey = base64ToUint8Array(parsed.edPublicKey);
432
- const edSecretKey = base64ToUint8Array(parsed.edSecretKeyBase64);
433
- const mlDsaPublicKey = base64ToUint8Array(parsed.mlDsaPublicKey);
434
- const mlDsaSecretKey = base64ToUint8Array(parsed.mlDsaSecretKeyBase64);
435
- const mlKemPublicKey = base64ToUint8Array(parsed.mlKemPublicKey);
436
- const mlKemSecretKey = base64ToUint8Array(parsed.mlKemSecretKeyBase64);
437
- const btcPublicKey = parsed.btcPublicKey
438
- ? base64ToUint8Array(parsed.btcPublicKey)
439
- : undefined;
440
- const btcSecretKey = parsed.btcSecretKeyBase64
441
- ? base64ToUint8Array(parsed.btcSecretKeyBase64)
442
- : undefined;
439
+ const anyParsed = parsed;
440
+ const store = Array.isArray(anyParsed.keys)
441
+ ? KeyStore.fromEntries(anyParsed.keys)
442
+ : KeyStore.fromLegacyJSON(parsed);
443
+ const secrets = new Map();
444
+ const put = (id, b64) => {
445
+ if (b64 && store.has(id))
446
+ secrets.set(id, base64ToUint8Array(b64));
447
+ };
448
+ put(KeyId.X25519, parsed.privateKeyBase64);
449
+ put(KeyId.ML_KEM_768, parsed.mlKemSecretKeyBase64);
450
+ put(KeyId.ED25519, parsed.edSecretKeyBase64);
451
+ put(KeyId.ML_DSA_87, parsed.mlDsaSecretKeyBase64);
452
+ put(KeyId.BTC, parsed.btcSecretKeyBase64);
453
+ for (const [id, b64] of Object.entries(parsed.secretKeys ?? {}))
454
+ if (store.has(id))
455
+ secrets.set(id, base64ToUint8Array(b64));
456
+ store.attachSecrets(secrets);
443
457
  return new MajikKey({
444
458
  id: parsed.id,
445
459
  fingerprint: parsed.fingerprint,
446
- publicKey: { raw: base64ToUint8Array(parsed.publicKey) },
447
- publicKeyBase64: parsed.publicKey,
448
- privateKey: { raw: privateKeyBytes },
449
- encryptedPrivateKey: new ArrayBuffer(0),
450
- encryptedPrivateKeyBase64: parsed.encryptedPrivateKey,
451
460
  salt: parsed.salt,
452
461
  backup: parsed.backup,
462
+ label: parsed.label || "",
463
+ timestamp: parsed.timestamp ? new Date(parsed.timestamp) : undefined,
453
464
  kdfVersion: parsed?.kdfVersion || KDF_VERSION.ARGON2ID,
454
- mlKemPublicKey,
455
- mlKemSecretKey,
456
- edPublicKey,
457
- edSecretKey,
458
- mlDsaPublicKey,
459
- mlDsaSecretKey,
460
- btcPublicKey,
461
- btcSecretKey,
465
+ mnemonicLanguage: parsed.mnemonicLanguage,
466
+ store,
462
467
  });
463
468
  }
464
469
  catch (err) {
@@ -481,24 +486,37 @@ export class MajikKey {
481
486
  language: this._mnemonicLanguage,
482
487
  };
483
488
  }
484
- static async fromMnemonicJSON(mnemonicJson, passphrase, label, options = {
485
- deriveBitcoin: true,
486
- mnemonicLanguage: "en",
487
- }) {
489
+ static async fromMnemonicJSON(mnemonicJson, passphrase, label, options = {}) {
488
490
  try {
489
491
  const parsed = typeof mnemonicJson === "string"
490
492
  ? JSON.parse(mnemonicJson)
491
493
  : mnemonicJson;
492
- if (!parsed.id || !parsed.seed || !Array.isArray(parsed.seed))
494
+ if (!parsed ||
495
+ !parsed.id ||
496
+ !Array.isArray(parsed.seed) ||
497
+ parsed.seed.length === 0) {
493
498
  throw new MajikKeyError("Invalid MnemonicJSON");
499
+ }
494
500
  const mnemonic = seedArrayToString(parsed.seed);
495
501
  MajikKeyValidator.validateMnemonic(mnemonic);
496
- return await MajikKey.create(mnemonic, passphrase, label, options);
502
+ // Explicit caller option wins.
503
+ // Otherwise preserve the language embedded
504
+ // in the MnemonicJSON.
505
+ const mnemonicLanguage = options.mnemonicLanguage ?? parsed.language ?? "en";
506
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
507
+ if (!validateMnemonic(mnemonic, wordlist)) {
508
+ throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
509
+ }
510
+ return await MajikKey.create(mnemonic, passphrase, label, {
511
+ ...options,
512
+ mnemonicLanguage,
513
+ });
497
514
  }
498
515
  catch (err) {
499
- if (err instanceof MajikKeyError)
516
+ if (err instanceof MajikKeyError) {
500
517
  throw err;
501
- throw new MajikKeyError("Failed to create MajikKey from MnemonicJSON", err);
518
+ }
519
+ throw new MajikKeyError("Failed to import MnemonicJSON", err);
502
520
  }
503
521
  }
504
522
  // ── UPDATE ───────────────────────────────────────────────────────────────────
@@ -512,54 +530,8 @@ export class MajikKey {
512
530
  throw new MajikKeyError("MajikKey must be unlocked to update passphrase");
513
531
  MajikKeyValidator.validatePassphrase(currentPassphrase, "Current passphrase");
514
532
  MajikKeyValidator.validatePassphrase(newPassphrase, "New passphrase");
515
- const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
516
- const privateKeyBuffer = await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, currentPassphrase, salt, this._kdfVersion);
517
533
  try {
518
- let mlKemSecretKeyBytes;
519
- if (this._encryptedMlKemSecretKey) {
520
- mlKemSecretKeyBytes = await MajikKey._decryptMlKemSecretKey(this._encryptedMlKemSecretKey, currentPassphrase, salt);
521
- }
522
- const newSalt = generateRandomBytes(SALT_SIZE);
523
- const { blob: newEncryptedPrivateKey } = await MajikKey._encryptPrivateKey(privateKeyBuffer, newPassphrase, newSalt);
524
- this._encryptedPrivateKey = newEncryptedPrivateKey;
525
- this._encryptedPrivateKeyBase64 = arrayBufferToBase64(newEncryptedPrivateKey);
526
- this._salt = arrayToBase64(newSalt);
527
- this._kdfVersion = KDF_VERSION.ARGON2ID;
528
- if (mlKemSecretKeyBytes) {
529
- const encMlKem = await MajikKey._encryptMlKemSecretKey(mlKemSecretKeyBytes, newPassphrase, newSalt);
530
- this._encryptedMlKemSecretKey = encMlKem;
531
- this._encryptedMlKemSecretKeyBase64 = arrayBufferToBase64(encMlKem);
532
- if (this._mlKemSecretKey)
533
- secureFill.call(this._mlKemSecretKey, 0);
534
- this._mlKemSecretKey = mlKemSecretKeyBytes;
535
- }
536
- if (this._encryptedEdSecretKey) {
537
- const edSecretKeyBytes = await MajikKey._decryptSigningKey(this._encryptedEdSecretKey, currentPassphrase, salt);
538
- const encEd = await MajikKey._encryptSigningKey(edSecretKeyBytes, newPassphrase, newSalt);
539
- this._encryptedEdSecretKey = encEd;
540
- this._encryptedEdSecretKeyBase64 = arrayBufferToBase64(encEd);
541
- if (this._edSecretKey)
542
- secureFill.call(this._edSecretKey, 0);
543
- this._edSecretKey = edSecretKeyBytes;
544
- }
545
- if (this._encryptedMlDsaSecretKey) {
546
- const mlDsaSecretKeyBytes = await MajikKey._decryptSigningKey(this._encryptedMlDsaSecretKey, currentPassphrase, salt);
547
- const encDsa = await MajikKey._encryptSigningKey(mlDsaSecretKeyBytes, newPassphrase, newSalt);
548
- this._encryptedMlDsaSecretKey = encDsa;
549
- this._encryptedMlDsaSecretKeyBase64 = arrayBufferToBase64(encDsa);
550
- if (this._mlDsaSecretKey)
551
- secureFill.call(this._mlDsaSecretKey, 0);
552
- this._mlDsaSecretKey = mlDsaSecretKeyBytes;
553
- }
554
- if (this._encryptedBtcSecretKey) {
555
- const btcSecretKeyBytes = await MajikKey._decryptSigningKey(this._encryptedBtcSecretKey, currentPassphrase, salt);
556
- const encBtc = await MajikKey._encryptSigningKey(btcSecretKeyBytes, newPassphrase, newSalt);
557
- this._encryptedBtcSecretKey = encBtc;
558
- this._encryptedBtcSecretKeyBase64 = arrayBufferToBase64(encBtc);
559
- if (this._btcSecretKey)
560
- secureFill.call(this._btcSecretKey, 0);
561
- this._btcSecretKey = btcSecretKeyBytes;
562
- }
534
+ await this._reencryptAll(currentPassphrase, newPassphrase);
563
535
  return this;
564
536
  }
565
537
  catch (err) {
@@ -567,30 +539,17 @@ export class MajikKey {
567
539
  throw err;
568
540
  throw new MajikKeyError("Failed to update passphrase", err);
569
541
  }
570
- finally {
571
- // Clear the temporary unencrypted buffer
572
- secureFill.call(new Uint8Array(privateKeyBuffer), 0);
573
- secureFill.call(salt, 0);
574
- }
575
542
  }
576
543
  /**
577
544
  * Migrate KDF from PBKDF2 to Argon2id without changing passphrase.
578
- * NOTE: Does not add ML-KEM keys — use importFromMnemonicBackup() for full upgrade.
545
+ * Does not add new key types — use addKeys() (requires the mnemonic).
579
546
  */
580
547
  async migrate(passphrase) {
581
548
  MajikKeyValidator.validatePassphrase(passphrase);
582
549
  if (this._kdfVersion === KDF_VERSION.ARGON2ID)
583
550
  return this;
584
- const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
585
- let privateKeyBuffer;
586
551
  try {
587
- privateKeyBuffer = await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt, KDF_VERSION.PBKDF2);
588
- const newSalt = generateRandomBytes(SALT_SIZE);
589
- const { blob } = await MajikKey._encryptPrivateKey(privateKeyBuffer, passphrase, newSalt);
590
- this._encryptedPrivateKey = blob;
591
- this._encryptedPrivateKeyBase64 = arrayBufferToBase64(blob);
592
- this._salt = arrayToBase64(newSalt);
593
- this._kdfVersion = KDF_VERSION.ARGON2ID;
552
+ await this._reencryptAll(passphrase, passphrase);
594
553
  return this;
595
554
  }
596
555
  catch (err) {
@@ -598,66 +557,107 @@ export class MajikKey {
598
557
  throw err;
599
558
  throw new MajikKeyError("Failed to migrate MajikKey to Argon2id", err);
600
559
  }
601
- finally {
602
- if (privateKeyBuffer)
603
- secureFill.call(new Uint8Array(privateKeyBuffer), 0);
604
- secureFill.call(salt, 0);
560
+ }
561
+ /**
562
+ * Add keypairs this account doesn't have yet (new algorithms, or core keys
563
+ * missing on a legacy account). Requires the original MNEMONIC: new keys are
564
+ * derived from the seed, which is never stored. Also requires the current
565
+ * passphrase (to encrypt the new keys under the account's existing salt).
566
+ *
567
+ * Safe by construction: the mnemonic must reproduce this account's X25519
568
+ * key, and the passphrase must decrypt it, before anything is added.
569
+ * Keys already present are skipped. Account must be on Argon2id — call
570
+ * `migrate(passphrase)` first if `isArgon2id` is false.
571
+ *
572
+ * @returns the ids that were added
573
+ */
574
+ async addKeys(ids, mnemonic, passphrase) {
575
+ try {
576
+ MajikKeyValidator.validateMnemonic(mnemonic);
577
+ MajikKeyValidator.validatePassphrase(passphrase);
578
+ if (!this.isArgon2id)
579
+ throw new MajikKeyError("Account is on the legacy KDF. Call migrate(passphrase) before addKeys().");
580
+ // resolveRequestedKeys validates status/implementation; we only add what was asked for AND is missing.
581
+ const resolved = resolveRequestedKeys(ids);
582
+ const toAdd = [...new Set(ids)].filter((id) => resolved.includes(id) && !this._store.has(id));
583
+ if (toAdd.length === 0)
584
+ return [];
585
+ const wordlist = await MajikKey._getWordlist(this._mnemonicLanguage);
586
+ if (!validateMnemonic(mnemonic, wordlist))
587
+ throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
588
+ const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
589
+ const aesKey = await MajikKey._deriveVaultKey(passphrase, salt);
590
+ const seed64 = await mnemonicToSeed(mnemonic);
591
+ try {
592
+ // 1) passphrase must be right
593
+ const xBlob = this._store.slot(KeyId.X25519).encryptedSecretKey;
594
+ if (!xBlob)
595
+ throw new MajikKeyError("Account has no encrypted X25519 key");
596
+ KeyStore.open(aesKey, xBlob, "classic:x25519 secret key");
597
+ // 2) mnemonic must belong to this account
598
+ const probe = deriveKeys(seed64, [KeyId.X25519]).get(KeyId.X25519);
599
+ if (fingerprintFromPublicRaw(probe.publicKey) !== this._fingerprint)
600
+ throw new MajikKeyError("That mnemonic does not belong to this account");
601
+ // 3) derive + seal + add
602
+ const derived = deriveKeys(seed64, toAdd);
603
+ for (const [id, kp] of derived) {
604
+ const slot = {
605
+ id,
606
+ publicKey: kp.publicKey,
607
+ encryptedSecretKey: KeyStore.seal(aesKey, kp.secretKey),
608
+ derivation: KEY_ALGORITHMS[id].derivation,
609
+ createdAt: new Date().toISOString(),
610
+ };
611
+ if (this.isUnlocked)
612
+ slot.secretKey = kp.secretKey;
613
+ else
614
+ secureFill.call(kp.secretKey, 0);
615
+ this._store.add(slot);
616
+ }
617
+ return toAdd;
618
+ }
619
+ finally {
620
+ secureFill.call(aesKey, 0);
621
+ secureFill.call(seed64, 0);
622
+ secureFill.call(salt, 0);
623
+ }
624
+ }
625
+ catch (err) {
626
+ if (err instanceof MajikKeyError)
627
+ throw err;
628
+ throw new MajikKeyError("Failed to add keys", err);
605
629
  }
606
630
  }
607
631
  // ── LOCK / UNLOCK ────────────────────────────────────────────────────────────
608
- // required
609
632
  lock() {
610
- // 1. Zeroize raw bytes of all active keys
611
- // Apply the secure fill using .call(targetArray, value)
612
- if (this._privateKey &&
613
- "raw" in this._privateKey &&
614
- this._privateKey.raw instanceof Uint8Array) {
615
- secureFill.call(this._privateKey.raw, 0);
616
- }
617
- if (this._mlKemSecretKey)
618
- secureFill.call(this._mlKemSecretKey, 0);
619
- if (this._edSecretKey)
620
- secureFill.call(this._edSecretKey, 0);
621
- if (this._mlDsaSecretKey)
622
- secureFill.call(this._mlDsaSecretKey, 0);
623
- if (this._btcSecretKey)
624
- secureFill.call(this._btcSecretKey, 0);
633
+ this._store.lock();
625
634
  if (this._solanaKeypairMaterial) {
626
635
  secureFill.call(this._solanaKeypairMaterial.secretKey, 0);
627
636
  }
628
- this._privateKey = undefined;
629
- this._mlKemSecretKey = undefined;
630
- this._edSecretKey = undefined;
631
- this._mlDsaSecretKey = undefined;
632
- this._btcSecretKey = undefined;
633
637
  this._solanaKeypairMaterial = undefined;
634
638
  return this;
635
639
  }
640
+ /** One KDF run decrypts every key. Atomic: a failure leaves the account fully locked. */
636
641
  async unlock(passphrase) {
637
642
  try {
638
643
  if (this.isUnlocked)
639
644
  throw new MajikKeyError("MajikKey is already unlocked");
640
645
  MajikKeyValidator.validatePassphrase(passphrase);
641
646
  const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
642
- const privateKeyBuffer = await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt, this._kdfVersion);
643
- const privateKey = {
644
- type: "private",
645
- raw: new Uint8Array(privateKeyBuffer),
646
- };
647
- this._privateKey = privateKey;
648
- if (this._encryptedMlKemSecretKey) {
649
- this._mlKemSecretKey = await MajikKey._decryptMlKemSecretKey(this._encryptedMlKemSecretKey, passphrase, salt);
650
- }
651
- if (this._encryptedEdSecretKey) {
652
- this._edSecretKey = await MajikKey._decryptSigningKey(this._encryptedEdSecretKey, passphrase, salt);
653
- }
654
- if (this._encryptedMlDsaSecretKey) {
655
- this._mlDsaSecretKey = await MajikKey._decryptSigningKey(this._encryptedMlDsaSecretKey, passphrase, salt);
647
+ const primaryKey = await MajikKey._deriveVaultKey(passphrase, salt, this._kdfVersion);
648
+ let argonKey = this._kdfVersion === KDF_VERSION.ARGON2ID ? primaryKey : undefined;
649
+ try {
650
+ if (!argonKey && this._hasNonX25519Blobs())
651
+ argonKey = await MajikKey._deriveVaultKey(passphrase, salt, KDF_VERSION.ARGON2ID);
652
+ this._store.unlock((slot) => slot.id === KeyId.X25519 ? primaryKey : argonKey);
653
+ return this;
656
654
  }
657
- if (this._encryptedBtcSecretKey) {
658
- this._btcSecretKey = await MajikKey._decryptSigningKey(this._encryptedBtcSecretKey, passphrase, salt);
655
+ finally {
656
+ secureFill.call(primaryKey, 0);
657
+ if (argonKey && argonKey !== primaryKey)
658
+ secureFill.call(argonKey, 0);
659
+ secureFill.call(salt, 0);
659
660
  }
660
- return this;
661
661
  }
662
662
  catch (err) {
663
663
  if (err instanceof MajikKeyError)
@@ -668,68 +668,26 @@ export class MajikKey {
668
668
  async verify(passphrase) {
669
669
  try {
670
670
  const salt = new Uint8Array(base64ToArrayBuffer(this._salt));
671
- await MajikKey._decryptPrivateKey(this._encryptedPrivateKey, passphrase, salt, this._kdfVersion);
672
- return true;
671
+ const key = await MajikKey._deriveVaultKey(passphrase, salt, this._kdfVersion);
672
+ try {
673
+ KeyStore.open(key, this._store.slot(KeyId.X25519).encryptedSecretKey, "private key");
674
+ return true;
675
+ }
676
+ finally {
677
+ secureFill.call(key, 0);
678
+ }
673
679
  }
674
680
  catch {
675
681
  return false;
676
682
  }
677
683
  }
678
- getPrivateKey() {
679
- if (this.isLocked)
680
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
681
- return this._privateKey;
682
- }
684
+ /** @deprecated Use `getPrivateKey(KeyId.X25519)`. */
683
685
  getPrivateKeyBase64() {
684
- if (this.isLocked)
685
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
686
- return arrayToBase64(this._privateKey.raw);
687
- }
688
- getMlKemSecretKey() {
689
- if (this.isLocked)
690
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
691
- if (!this._mlKemSecretKey)
692
- throw new MajikKeyError("No ML-KEM secret key — re-import via importFromMnemonicBackup() for full migration.");
693
- return this._mlKemSecretKey;
694
- }
695
- getEdSecretKey() {
696
- if (this.isLocked)
697
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
698
- if (!this._edSecretKey)
699
- throw new MajikKeyError("No Ed25519 secret key — re-import via importFromMnemonicBackup() for full migration.");
700
- return this._edSecretKey;
701
- }
702
- getMlDsaSecretKey() {
703
- if (this.isLocked)
704
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
705
- if (!this._mlDsaSecretKey)
706
- throw new MajikKeyError("No ML-DSA secret key — re-import via importFromMnemonicBackup() for full migration.");
707
- return this._mlDsaSecretKey;
686
+ return arrayToBase64(this._requireSecret(KeyId.X25519));
708
687
  }
709
688
  /**
710
689
  * Executes an operation against an already-unlocked MajikKey and
711
- * automatically locks the key when the operation completes.
712
- *
713
- * The key is always locked after the operation, including when the
714
- * operation throws or rejects.
715
- *
716
- * @param key - An already-unlocked MajikKey instance.
717
- * @param operation - Synchronous or asynchronous operation to execute.
718
- * @returns The result returned by the operation.
719
- *
720
- * @throws {MajikKeyError} If the key is locked.
721
- * @throws {MajikKeyError} If `operation` is not a function.
722
- *
723
- * @example
724
- * ```ts
725
- * await key.unlock(passphrase);
726
- *
727
- * const signature = await MajikKey.withAutoLock(key, async (key) => {
728
- * return sign(key.getEdSecretKey(), message);
729
- * });
730
- *
731
- * // key.isLocked === true
732
- * ```
690
+ * automatically locks the key when the operation completes (even on throw).
733
691
  */
734
692
  static async withAutoLock(key, operation) {
735
693
  if (!(key instanceof MajikKey)) {
@@ -748,35 +706,66 @@ export class MajikKey {
748
706
  key.lock();
749
707
  }
750
708
  }
709
+ _hasNonX25519Blobs() {
710
+ return this._store
711
+ .ids()
712
+ .some((id) => id !== KeyId.X25519 && !!this._store.slot(id)?.encryptedSecretKey);
713
+ }
714
+ /**
715
+ * Decrypt every blob under (current passphrase, current salt/KDF), re-encrypt
716
+ * under (new passphrase, fresh salt, Argon2id), then commit atomically.
717
+ * Does not need the account to be unlocked and never touches plaintext in memory.
718
+ */
719
+ async _reencryptAll(currentPassphrase, newPassphrase) {
720
+ const oldSalt = new Uint8Array(base64ToArrayBuffer(this._salt));
721
+ const newSalt = generateRandomBytes(SALT_SIZE);
722
+ let oldPrimary;
723
+ let oldArgon;
724
+ let newKey;
725
+ try {
726
+ oldPrimary = await MajikKey._deriveVaultKey(currentPassphrase, oldSalt, this._kdfVersion);
727
+ oldArgon =
728
+ this._kdfVersion === KDF_VERSION.ARGON2ID ? oldPrimary : undefined;
729
+ if (!oldArgon && this._hasNonX25519Blobs())
730
+ oldArgon = await MajikKey._deriveVaultKey(currentPassphrase, oldSalt, KDF_VERSION.ARGON2ID);
731
+ newKey = await MajikKey._deriveVaultKey(newPassphrase, newSalt, KDF_VERSION.ARGON2ID);
732
+ const blobs = this._store.prepareReseal((slot) => (slot.id === KeyId.X25519 ? oldPrimary : oldArgon), newKey);
733
+ this._store.commitReseal(blobs);
734
+ this._salt = arrayToBase64(newSalt);
735
+ this._kdfVersion = KDF_VERSION.ARGON2ID;
736
+ }
737
+ finally {
738
+ if (oldPrimary)
739
+ secureFill.call(oldPrimary, 0);
740
+ if (oldArgon && oldArgon !== oldPrimary)
741
+ secureFill.call(oldArgon, 0);
742
+ if (newKey)
743
+ secureFill.call(newKey, 0);
744
+ secureFill.call(oldSalt, 0);
745
+ }
746
+ }
751
747
  // ── SERIALIZATION ────────────────────────────────────────────────────────────
752
- toJSON() {
748
+ /**
749
+ * Serialize (safe at rest: only passphrase-encrypted secrets).
750
+ * Writes the registry (`keys`) AND, by default, the pre-registry flat fields
751
+ * for compatibility. Pass `{ legacy: false }` for registry-only output.
752
+ * (JSON.stringify passes a string here; that is treated as "defaults".)
753
+ */
754
+ toJSON(options) {
755
+ const legacy = !(typeof options === "object" && options?.legacy === false);
753
756
  return {
754
757
  id: this._id,
755
758
  label: this._label,
756
759
  publicKey: this._publicKeyBase64,
757
760
  fingerprint: this._fingerprint,
758
- encryptedPrivateKey: this._encryptedPrivateKeyBase64,
759
761
  salt: this._salt,
760
762
  backup: this._backup,
761
763
  timestamp: this._timestamp.toISOString(),
762
764
  kdfVersion: this._kdfVersion,
763
- mlKemPublicKey: this._mlKemPublicKey
764
- ? arrayToBase64(this._mlKemPublicKey)
765
- : undefined,
766
- encryptedMlKemSecretKey: this._encryptedMlKemSecretKeyBase64,
767
- edPublicKey: this._edPublicKey
768
- ? arrayToBase64(this._edPublicKey)
769
- : undefined,
770
- encryptedEdSecretKey: this._encryptedEdSecretKeyBase64,
771
- mlDsaPublicKey: this._mlDsaPublicKey
772
- ? arrayToBase64(this._mlDsaPublicKey)
773
- : undefined,
774
- encryptedMlDsaSecretKey: this._encryptedMlDsaSecretKeyBase64,
775
- btcPublicKey: this._btcPublicKey
776
- ? arrayToBase64(this._btcPublicKey)
777
- : undefined,
778
- encryptedBtcSecretKey: this._encryptedBtcSecretKeyBase64,
779
765
  mnemonicLanguage: this._mnemonicLanguage,
766
+ keysVersion: KEYS_VERSION,
767
+ keys: this._store.toEntries(),
768
+ ...(legacy ? this._store.toLegacyJSON() : {}),
780
769
  };
781
770
  }
782
771
  toString(pretty = false) {
@@ -810,27 +799,34 @@ export class MajikKey {
810
799
  fingerprint: this._fingerprint,
811
800
  meta: { label: this._label, ...initialMeta },
812
801
  mlKey: arrayToBase64(this.mlKemPublicKey),
813
- edPublicKeyBase64: this._edPublicKey
814
- ? arrayToBase64(this._edPublicKey)
802
+ edPublicKeyBase64: this.edPublicKey
803
+ ? arrayToBase64(this.edPublicKey)
815
804
  : undefined,
816
- mlDsaPublicKeyBase64: this._mlDsaPublicKey
817
- ? arrayToBase64(this._mlDsaPublicKey)
805
+ mlDsaPublicKeyBase64: this.mlDsaPublicKey
806
+ ? arrayToBase64(this.mlDsaPublicKey)
818
807
  : undefined,
819
808
  });
820
809
  }
821
810
  toKeyIdentity() {
822
811
  if (this.isLocked)
823
812
  throw new MajikKeyError("Cannot convert locked MajikKey to KeyIdentity. Unlock first.");
813
+ const blob = this._store.slot(KeyId.X25519).encryptedSecretKey;
824
814
  return {
825
815
  id: this._id,
826
816
  publicKey: this._publicKey,
827
817
  fingerprint: this._fingerprint,
828
- privateKey: this._privateKey,
829
- encryptedPrivateKey: this._encryptedPrivateKey,
818
+ privateKey: { raw: this._requireSecret(KeyId.X25519) },
819
+ encryptedPrivateKey: blob.slice().buffer,
830
820
  salt: this._salt,
831
821
  kdfVersion: this._kdfVersion,
832
- mlKemPublicKey: this._mlKemPublicKey,
833
- mlKemSecretKey: this._mlKemSecretKey,
822
+ mlKemPublicKey: this.mlKemPublicKey,
823
+ mlKemSecretKey: this.mlKemSecretKey,
824
+ edPublicKey: this.edPublicKey,
825
+ edSecretKey: this._store.peekSecretKey(KeyId.ED25519),
826
+ mlDsaPublicKey: this.mlDsaPublicKey,
827
+ mlDsaSecretKey: this._store.peekSecretKey(KeyId.ML_DSA_87),
828
+ btcPublicKey: this.btcPublicKey,
829
+ btcSecretKey: this._store.peekSecretKey(KeyId.BTC),
834
830
  };
835
831
  }
836
832
  toSerializedIdentity() {
@@ -840,7 +836,7 @@ export class MajikKey {
840
836
  id: this._id,
841
837
  publicKey: this._publicKeyBase64,
842
838
  fingerprint: this._fingerprint,
843
- encryptedPrivateKey: this._encryptedPrivateKeyBase64,
839
+ encryptedPrivateKey: arrayToBase64(this._store.slot(KeyId.X25519).encryptedSecretKey),
844
840
  salt: this._salt,
845
841
  };
846
842
  }
@@ -857,24 +853,27 @@ export class MajikKey {
857
853
  if (this.isLocked)
858
854
  throw new MajikKeyError("MajikKey must be unlocked to export backup");
859
855
  MajikKeyValidator.validateMnemonic(mnemonic);
860
- return MajikKey._exportMnemonicBackup(this.toKeyIdentity(), mnemonic);
856
+ return MajikKey._exportMnemonicBackup({
857
+ id: this._id,
858
+ fingerprint: this._fingerprint,
859
+ publicRaw: this._publicKey.raw,
860
+ privateRaw: this._requireSecret(KeyId.X25519),
861
+ }, mnemonic);
861
862
  }
862
863
  /**
863
- * Import a MajikKey from a mnemonic-encrypted backup.
864
- *
864
+ * Import a MajikKey from a mnemonic-encrypted backup. Re-derives the account
865
+ * from the mnemonic: the core four (plus `options.keys`) under a new passphrase.
865
866
  */
866
- static async importFromMnemonicBackup(backup, mnemonic, passphrase, label, options = {
867
- deriveBitcoin: true,
868
- mnemonicLanguage: "en",
869
- }) {
867
+ static async importFromMnemonicBackup(backup, mnemonic, passphrase, label, options = {}) {
870
868
  try {
871
869
  if (!backup || typeof backup !== "string")
872
870
  throw new MajikKeyError("Backup must be a non-empty string");
873
871
  MajikKeyValidator.validateMnemonic(mnemonic);
874
872
  MajikKeyValidator.validatePassphrase(passphrase);
875
873
  MajikKeyValidator.validateLabel(label);
876
- const { deriveBitcoin, mnemonicLanguage } = options;
877
- const wordlist = await MajikKey._getWordlist(mnemonicLanguage || "en");
874
+ const mnemonicLanguage = options.mnemonicLanguage || "en";
875
+ const ids = MajikKey._resolveCreateKeys(options);
876
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
878
877
  if (!validateMnemonic(mnemonic, wordlist)) {
879
878
  throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
880
879
  }
@@ -889,43 +888,18 @@ export class MajikKey {
889
888
  const backupKdfVersion = parsed.backupKdfVersion ??
890
889
  KDF_VERSION.PBKDF2;
891
890
  // Verify mnemonic is correct before doing expensive re-derivation
892
- await MajikKey._verifyBackupDecryption(parsed.iv, parsed.ciphertext, mnemonic, backupKdfVersion);
893
- // Re-derive complete identity from mnemonic — gets ML-KEM for free
894
- const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase, { deriveBitcoin: deriveBitcoin });
895
- const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
896
- const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
897
- const id = parsed.id || identity.id;
891
+ await MajikKey._verifyBackupDecryption(parsed.iv, parsed.ciphertext, mnemonic, backupKdfVersion, parsed.backupSaltVersion);
892
+ const d = await MajikKey._deriveFromMnemonic(mnemonic, passphrase, ids);
898
893
  return new MajikKey({
899
- id,
900
- publicKey: identity.publicKey,
901
- publicKeyBase64,
902
- fingerprint: identity.fingerprint,
903
- encryptedPrivateKey: identity.encryptedPrivateKey,
904
- encryptedPrivateKeyBase64: arrayBufferToBase64(identity.encryptedPrivateKey),
905
- salt: identity.salt,
894
+ id: parsed.id || d.fingerprint,
895
+ fingerprint: d.fingerprint,
896
+ salt: d.salt,
906
897
  backup,
907
898
  label: label || "",
908
899
  timestamp: new Date(),
909
900
  kdfVersion: KDF_VERSION.ARGON2ID,
910
- mlKemPublicKey: identity.mlKemPublicKey,
911
- mlKemSecretKey: identity.mlKemSecretKey,
912
- encryptedMlKemSecretKey: identity.encryptedMlKemSecretKey,
913
- encryptedMlKemSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlKemSecretKey),
914
- privateKey: identity.privateKey,
915
- edPublicKey: identity.edPublicKey,
916
- encryptedEdSecretKey: identity.encryptedEdSecretKey,
917
- encryptedEdSecretKeyBase64: arrayBufferToBase64(identity.encryptedEdSecretKey),
918
- mlDsaPublicKey: identity.mlDsaPublicKey,
919
- encryptedMlDsaSecretKey: identity.encryptedMlDsaSecretKey,
920
- encryptedMlDsaSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlDsaSecretKey),
921
- edSecretKey: identity.edSecretKey,
922
- mlDsaSecretKey: identity.mlDsaSecretKey,
923
- btcPublicKey: identity.btcPublicKey,
924
- encryptedBtcSecretKey: identity.encryptedBtcSecretKey,
925
- encryptedBtcSecretKeyBase64: identity.encryptedBtcSecretKey
926
- ? arrayBufferToBase64(identity.encryptedBtcSecretKey)
927
- : undefined,
928
- btcSecretKey: identity.btcSecretKey,
901
+ mnemonicLanguage, // fix: previously dropped, silently resetting to "en"
902
+ store: d.store,
929
903
  });
930
904
  }
931
905
  catch (err) {
@@ -934,138 +908,68 @@ export class MajikKey {
934
908
  throw new MajikKeyError("Failed to import from mnemonic backup", err);
935
909
  }
936
910
  }
937
- // ── PRIVATE: Core Derivation ─────────────────────────────────────────────────
911
+ // ── PRIVATE: derivation + vault crypto ───────────────────────────────────────
938
912
  static async _getWordlist(language) {
913
+ const supported = [
914
+ "en",
915
+ "fr",
916
+ "es",
917
+ "it",
918
+ "ja",
919
+ "ko",
920
+ "czech",
921
+ "pt",
922
+ "zh-cn",
923
+ "zh-tw",
924
+ ];
925
+ if (!supported.includes(language)) {
926
+ throw new MajikKeyError(`Unsupported language: ${String(language)}`);
927
+ }
939
928
  const loader = WORDLISTS[language] ?? WORDLISTS.en;
940
929
  const mod = await loader();
941
930
  return mod.wordlist;
942
931
  }
943
932
  /**
944
- * @param mnemonic - BIP-39 mnemonic to derive the full key set from.
945
- * @param passphrase - Passphrase used to derive the Argon2id encryption key shared by every private key produced here.
946
- * @param options.deriveBitcoin - @experimental Set `false` to skip deriving the Bitcoin keypair. Defaults to `true`.
933
+ * Derive `ids` from the mnemonic and seal each secret under ONE Argon2id
934
+ * key (single salt, single KDF run). Returns an UNLOCKED store.
947
935
  */
948
- static async _deriveAndEncryptFromMnemonic(mnemonic, passphrase, options) {
949
- const deriveBitcoin = options?.deriveBitcoin ?? true;
950
- const encIdentity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
951
- const anyPriv = encIdentity.privateKey;
952
- const exportedXPrivate = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
953
- // Single salt — one Argon2id derivation unlocks every key below
954
- const salt = generateRandomBytes(SALT_SIZE);
955
- const { blob: encryptedPrivateKey } = await MajikKey._encryptPrivateKey(exportedXPrivate, passphrase, salt);
956
- const mlKemSecretKey = encIdentity.mlKemSecretKey;
957
- const encryptedMlKemSecretKey = await MajikKey._encryptMlKemSecretKey(mlKemSecretKey, passphrase, salt);
958
- const edSecretKey = encIdentity.edSecretKey;
959
- const encryptedEdSecretKey = await MajikKey._encryptSigningKey(edSecretKey, passphrase, salt);
960
- const mlDsaSecretKey = encIdentity.mlDsaSecretKey;
961
- const encryptedMlDsaSecretKey = await MajikKey._encryptSigningKey(mlDsaSecretKey, passphrase, salt);
962
- // @experimental Bitcoin — real BIP-32/BIP-84 off the raw 64-byte BIP-39
963
- // seed, using Majik's domain-separated path by default. Same salt,
964
- // different IV, same pattern as ML-KEM/Ed25519/ML-DSA above. Skipped
965
- // entirely when `deriveBitcoin` is false — no derivation cost paid,
966
- // no key material generated.
967
- let btcPublicKey;
968
- let btcSecretKey;
969
- let encryptedBtcSecretKey;
970
- if (deriveBitcoin) {
971
- const rawSeed = await mnemonicToSeed(mnemonic);
972
- const btcMaterial = deriveBitcoinKeypairFromSeed(rawSeed);
973
- btcPublicKey = btcMaterial.publicKey;
974
- btcSecretKey = btcMaterial.privateKey;
975
- encryptedBtcSecretKey = await MajikKey._encryptSigningKey(btcMaterial.privateKey, passphrase, salt);
936
+ static async _deriveFromMnemonic(mnemonic, passphrase, ids) {
937
+ const seed64 = await mnemonicToSeed(mnemonic);
938
+ let derived;
939
+ try {
940
+ derived = deriveKeys(seed64, ids);
976
941
  }
977
- return {
978
- id: encIdentity.fingerprint,
979
- publicKey: encIdentity.publicKey,
980
- fingerprint: encIdentity.fingerprint,
981
- privateKey: encIdentity.privateKey,
982
- encryptedPrivateKey,
983
- salt: arrayToBase64(salt),
984
- kdfVersion: KDF_VERSION.ARGON2ID,
985
- mlKemPublicKey: encIdentity.mlKemPublicKey,
986
- mlKemSecretKey,
987
- encryptedMlKemSecretKey,
988
- edPublicKey: encIdentity.edPublicKey,
989
- edSecretKey,
990
- encryptedEdSecretKey,
991
- mlDsaPublicKey: encIdentity.mlDsaPublicKey,
992
- mlDsaSecretKey,
993
- encryptedMlDsaSecretKey,
994
- btcPublicKey,
995
- btcSecretKey,
996
- encryptedBtcSecretKey,
997
- };
998
- }
999
- // ── PRIVATE: Encryption/Decryption ───────────────────────────────────────────
1000
- static async _encryptPrivateKey(buffer, passphrase, salt) {
1001
- const keyBytes = await deriveKeyFromPassphraseArgon2(passphrase, salt);
1002
- const iv = generateRandomBytes(IV_LENGTH);
1003
- const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(buffer));
1004
- return {
1005
- blob: concatUint8Arrays(iv, ciphertext).buffer,
1006
- kdfVersion: KDF_VERSION.ARGON2ID,
1007
- };
1008
- }
1009
- /**
1010
- * Encrypt the ML-KEM secret key using the same Argon2id-derived key as X25519
1011
- * (same passphrase + same salt) but a DIFFERENT random IV. One Argon2id
1012
- * computation → two independently encrypted blobs.
1013
- */
1014
- static async _encryptMlKemSecretKey(mlKemSecretKey, passphrase, salt) {
1015
- const keyBytes = await deriveKeyFromPassphraseArgon2(passphrase, salt);
1016
- const iv = generateRandomBytes(IV_LENGTH); // different IV from X25519 blob
1017
- const ciphertext = aesGcmEncrypt(keyBytes, iv, mlKemSecretKey);
1018
- return concatUint8Arrays(iv, ciphertext).buffer;
1019
- }
1020
- static async _encryptSigningKey(keyBytes_, passphrase, salt) {
1021
- const aesKey = await deriveKeyFromPassphraseArgon2(passphrase, salt);
1022
- const iv = generateRandomBytes(IV_LENGTH);
1023
- const ciphertext = aesGcmEncrypt(aesKey, iv, keyBytes_);
1024
- return concatUint8Arrays(iv, ciphertext).buffer;
1025
- }
1026
- static async _decryptPrivateKey(buffer, passphrase, salt, kdfVersion = KDF_VERSION.PBKDF2) {
1027
- const keyBytes = kdfVersion === KDF_VERSION.ARGON2ID
1028
- ? await deriveKeyFromPassphraseArgon2(passphrase, salt)
1029
- : deriveKeyFromPassphrase(passphrase, salt);
1030
- const full = new Uint8Array(buffer);
1031
- const iv = full.slice(0, IV_LENGTH);
1032
- const ciphertext = full.slice(IV_LENGTH);
1033
- const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
1034
- if (!plain)
1035
- throw new MajikKeyError("Decryption failed — incorrect passphrase or corrupted data");
1036
- return plain.buffer;
1037
- }
1038
- static async _decryptMlKemSecretKey(buffer, passphrase, salt) {
1039
- // ML-KEM keys are only ever written by Argon2id (v2) code
1040
- const keyBytes = await deriveKeyFromPassphraseArgon2(passphrase, salt);
1041
- const full = new Uint8Array(buffer);
1042
- const iv = full.slice(0, IV_LENGTH);
1043
- const ciphertext = full.slice(IV_LENGTH);
1044
- const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
1045
- if (!plain)
1046
- throw new MajikKeyError("Failed to decrypt ML-KEM secret key");
1047
- return plain;
1048
- }
1049
- static async _decryptSigningKey(buffer, passphrase, salt) {
1050
- const keyBytes = await deriveKeyFromPassphraseArgon2(passphrase, salt);
942
+ finally {
943
+ secureFill.call(seed64, 0);
944
+ }
945
+ const salt = generateRandomBytes(SALT_SIZE);
946
+ const aesKey = await MajikKey._deriveVaultKey(passphrase, salt);
1051
947
  try {
1052
- const full = new Uint8Array(buffer);
1053
- const iv = full.slice(0, IV_LENGTH);
1054
- const ciphertext = full.slice(IV_LENGTH);
1055
- const plain = aesGcmDecrypt(keyBytes, iv, ciphertext);
1056
- if (!plain)
1057
- throw new MajikKeyError("Failed to decrypt signing key");
1058
- return plain;
948
+ const store = KeyStore.fromDerived(derived, aesKey);
949
+ const x = derived.get(KeyId.X25519);
950
+ return {
951
+ store,
952
+ salt: arrayToBase64(salt),
953
+ fingerprint: fingerprintFromPublicRaw(x.publicKey),
954
+ xPublic: x.publicKey,
955
+ xSecret: x.secretKey,
956
+ };
1059
957
  }
1060
958
  finally {
1061
- secureFill.call(keyBytes, 0);
959
+ secureFill.call(aesKey, 0);
1062
960
  }
1063
961
  }
962
+ /** One KDF run. kdfVersion 1 = legacy PBKDF2 (X25519 blob of old accounts only). */
963
+ static async _deriveVaultKey(passphrase, salt, kdfVersion = KDF_VERSION.ARGON2ID) {
964
+ return kdfVersion === KDF_VERSION.ARGON2ID
965
+ ? deriveKeyFromPassphraseArgon2(passphrase, salt)
966
+ : deriveKeyFromPassphrase(passphrase, salt);
967
+ }
1064
968
  // ── PRIVATE: Backup ──────────────────────────────────────────────────────────
1065
- static async _verifyBackupDecryption(ivBase64, ciphertextBase64, mnemonic, backupKdfVersion) {
969
+ static async _verifyBackupDecryption(ivBase64, ciphertextBase64, mnemonic, backupKdfVersion, backupSaltVersion) {
1066
970
  const iv = new Uint8Array(base64ToArrayBuffer(ivBase64));
1067
971
  const ciphertext = base64ToArrayBuffer(ciphertextBase64);
1068
- const mnemonicSalt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
972
+ const mnemonicSalt = new TextEncoder().encode(backupSaltFor(backupSaltVersion));
1069
973
  if (backupKdfVersion === KDF_VERSION.ARGON2ID) {
1070
974
  const keyBytes = await deriveKeyFromMnemonicArgon2(mnemonic, mnemonicSalt);
1071
975
  const plain = aesGcmDecrypt(keyBytes, iv, new Uint8Array(ciphertext));
@@ -1073,6 +977,7 @@ export class MajikKey {
1073
977
  throw new MajikKeyError("Failed to decrypt backup — invalid mnemonic or corrupted data");
1074
978
  }
1075
979
  else {
980
+ // PBKDF2 backups predate salt versioning: always the legacy salt.
1076
981
  const legacyKey = await MajikKey._deriveLegacyMnemonicKey(mnemonic);
1077
982
  try {
1078
983
  await crypto.subtle.decrypt({ name: "AES-GCM", iv }, legacyKey, ciphertext);
@@ -1083,49 +988,26 @@ export class MajikKey {
1083
988
  }
1084
989
  }
1085
990
  static async _exportMnemonicBackup(identity, mnemonic) {
1086
- if (!identity?.privateKey)
1087
- throw new MajikKeyError("Identity must have privateKey to export backup");
1088
- const anyPriv = identity.privateKey;
1089
- const anyPub = identity.publicKey;
1090
- const privRawBuf = anyPriv.raw.buffer.slice(anyPriv.raw.byteOffset, anyPriv.raw.byteOffset + anyPriv.raw.byteLength);
1091
- const pubRawBuf = anyPub.raw.buffer.slice(anyPub.raw.byteOffset, anyPub.raw.byteOffset + anyPub.raw.byteLength);
1092
- const mnemonicSalt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
991
+ const mnemonicSalt = new TextEncoder().encode(backupSaltFor(BACKUP_SALT_WRITE_VERSION));
1093
992
  const keyBytes = await deriveKeyFromMnemonicArgon2(mnemonic, mnemonicSalt);
1094
993
  const iv = generateRandomBytes(IV_LENGTH);
1095
- const ciphertext = aesGcmEncrypt(keyBytes, iv, new Uint8Array(privRawBuf));
994
+ const ciphertext = aesGcmEncrypt(keyBytes, iv, identity.privateRaw);
1096
995
  return utf8ToBase64(JSON.stringify({
1097
996
  id: identity.id,
1098
997
  iv: arrayToBase64(iv),
1099
998
  ciphertext: arrayToBase64(ciphertext),
1100
- publicKey: arrayBufferToBase64(pubRawBuf),
999
+ publicKey: arrayToBase64(identity.publicRaw),
1101
1000
  fingerprint: identity.fingerprint,
1102
1001
  backupKdfVersion: KDF_VERSION.ARGON2ID,
1002
+ backupSaltVersion: BACKUP_SALT_WRITE_VERSION,
1103
1003
  }));
1104
1004
  }
1105
1005
  static async _deriveLegacyMnemonicKey(mnemonic) {
1106
- const salt = new TextEncoder().encode(MAJIK_MNEMONIC_SALT);
1006
+ const salt = new TextEncoder().encode(LEGACY_MAJIK_MNEMONIC_SALT);
1107
1007
  const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(mnemonic), { name: "PBKDF2" }, false, ["deriveKey"]);
1108
1008
  return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
1109
1009
  }
1110
- static async _exportKeyToBase64(key) {
1111
- const anyKey = key;
1112
- if (anyKey?.raw instanceof Uint8Array)
1113
- return arrayBufferToBase64(anyKey.raw.buffer);
1114
- const raw = await crypto.subtle.exportKey("raw", key);
1115
- return arrayBufferToBase64(raw);
1116
- }
1117
1010
  // ── WEB3 (EXPERIMENTAL) ─────────────────────────────────────────────────────
1118
- /**
1119
- * @experimental
1120
- */
1121
- getBtcSecretKey() {
1122
- if (this.isLocked)
1123
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
1124
- if (!this._btcSecretKey)
1125
- throw new MajikKeyError("No Bitcoin secret key — re-import via importFromMnemonicBackup() for full migration.");
1126
- return this._btcSecretKey;
1127
- }
1128
- // ── WEB3 (EXPERIMENTAL) — updated getter ────────────────────────────────────
1129
1011
  /**
1130
1012
  * @experimental
1131
1013
  */
@@ -1133,8 +1015,19 @@ export class MajikKey {
1133
1015
  if (!this.hasSolanaKeypair)
1134
1016
  return undefined;
1135
1017
  const solanaMaterial = this._getOrDeriveSolanaMaterial();
1136
- const btcMaterial = this._btcSecretKey && this._btcPublicKey
1137
- ? { privateKey: this._btcSecretKey, publicKey: this._btcPublicKey }
1018
+ const btcSecret = this._store.peekSecretKey(KeyId.BTC);
1019
+ const btcMaterial = btcSecret && this._store.has(KeyId.BTC)
1020
+ ? {
1021
+ privateKey: btcSecret,
1022
+ publicKey: this._store.getPublicKey(KeyId.BTC),
1023
+ }
1024
+ : undefined;
1025
+ const ethSecret = this._store.peekSecretKey(KeyId.ETH);
1026
+ const ethMaterial = ethSecret && this._store.has(KeyId.ETH)
1027
+ ? {
1028
+ privateKey: ethSecret,
1029
+ publicKey: this._store.getPublicKey(KeyId.ETH),
1030
+ }
1138
1031
  : undefined;
1139
1032
  return {
1140
1033
  solana: {
@@ -1152,42 +1045,39 @@ export class MajikKey {
1152
1045
  getWIF: (options) => toWIF(btcMaterial, options),
1153
1046
  sign: (hash, scheme) => signWithBitcoinMaterial(btcMaterial, hash, scheme),
1154
1047
  },
1048
+ ethereum: ethMaterial && {
1049
+ publicKey: ethMaterial.publicKey,
1050
+ privateKey: ethMaterial.privateKey,
1051
+ address: ethereumAddressFromPublicKey(ethMaterial.publicKey),
1052
+ getPrivateKeyHex: () => toEthereumPrivateKeyHex(ethMaterial),
1053
+ signHash: (hash32) => signEthereumHash(ethMaterial, hash32),
1054
+ signMessage: (message) => signEthereumMessage(ethMaterial, message),
1055
+ },
1155
1056
  };
1156
1057
  }
1157
- // ── BITCON (EXPERIMENTAL) ────────────────────────────────────
1158
- /**
1159
- * @experimental True if this MajikKey can currently produce Bitcoin
1160
- * material (i.e. it's unlocked and has a Bitcoin secret key).
1161
- */
1058
+ // ── BITCOIN (EXPERIMENTAL) ──────────────────────────────────────────────────
1059
+ /** @experimental True if this MajikKey can currently produce Bitcoin material (unlocked + has a Bitcoin key). */
1162
1060
  get hasBitcoinKeypair() {
1163
- return this.isUnlocked && this._btcSecretKey !== undefined;
1061
+ return this._store.peekSecretKey(KeyId.BTC) !== undefined;
1164
1062
  }
1165
1063
  /**
1166
- * @experimental Raw Bitcoin keypair material. Pass `{ standard: true }` to
1167
- * get the REAL BIP-84 mainnet key (recoverable in any standard wallet from
1168
- * the mnemonic alone) instead of Majik's default domain-separated key.
1169
- *
1170
- * NOTE: `{ standard: true }` re-derives from the raw seed on demand and is
1171
- * NOT the same key as `web3.bitcoin` (which is always the stored,
1172
- * domain-separated default) — it requires the mnemonic to reproduce again
1173
- * outside Majik, whereas the stored default does not.
1064
+ * @experimental Raw Bitcoin keypair material for the stored (domain-separated)
1065
+ * key. The REAL BIP-84 key needs the mnemonic:
1066
+ * use `MajikKey.deriveStandardBitcoinFromMnemonic(mnemonic)`.
1174
1067
  */
1175
1068
  getBitcoinKeypairMaterial(options) {
1176
1069
  if (this.isLocked)
1177
1070
  throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
1178
1071
  if (!options?.standard && !options?.path) {
1179
- if (!this._btcSecretKey || !this._btcPublicKey)
1180
- throw new MajikKeyError("No Bitcoin secret key — re-import via importFromMnemonicBackup() first.");
1181
- return { privateKey: this._btcSecretKey, publicKey: this._btcPublicKey };
1072
+ return {
1073
+ privateKey: this.getBtcSecretKey(),
1074
+ publicKey: this._store.getPublicKey(KeyId.BTC),
1075
+ };
1182
1076
  }
1183
1077
  throw new MajikKeyError("Deriving the standard BIP-84 path requires the mnemonic — " +
1184
1078
  "use MajikKey.deriveStandardBitcoinFromMnemonic(mnemonic) instead.");
1185
1079
  }
1186
- /**
1187
- * @experimental Derive the REAL BIP-84 mainnet Bitcoin keypair straight
1188
- * from a mnemonic — for one-off export/verification. Does not require
1189
- * an unlocked MajikKey instance.
1190
- */
1080
+ /** @experimental Derive the REAL BIP-84 mainnet Bitcoin keypair straight from a mnemonic. */
1191
1081
  static async deriveStandardBitcoinFromMnemonic(mnemonic, mnemonicLanguage = "en") {
1192
1082
  MajikKeyValidator.validateMnemonic(mnemonic);
1193
1083
  const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
@@ -1197,55 +1087,61 @@ export class MajikKey {
1197
1087
  const seed = await mnemonicToSeed(mnemonic);
1198
1088
  return deriveBitcoinKeypairFromSeed(seed, { standard: true });
1199
1089
  }
1200
- /**
1201
- * @experimental WIF export of the default (domain-separated) Bitcoin key.
1202
- */
1090
+ /** @experimental WIF export of the stored (domain-separated) Bitcoin key. */
1203
1091
  getBitcoinWIF(options) {
1204
- const material = this.getBitcoinKeypairMaterial();
1205
- return toWIF(material, options);
1092
+ return toWIF(this.getBitcoinKeypairMaterial(), options);
1093
+ }
1094
+ // ── ETHEREUM (EXPERIMENTAL) ─────────────────────────────────────────────────
1095
+ /** @experimental True if this account has a stored Ethereum key (works while locked). */
1096
+ get hasEthereum() {
1097
+ return this._store.has(KeyId.ETH);
1206
1098
  }
1207
- // ── SOLANA (EXPERIMENTAL) ────────────────────────────────────
1208
1099
  /**
1209
- * @experimental True if this MajikKey can currently produce a Solana
1210
- * keypair (i.e. it's unlocked and has an Ed25519 signing key).
1100
+ * @experimental EIP-55 Ethereum address (standard m/44'/60'/0'/0/0 — the same
1101
+ * address MetaMask shows for this mnemonic). Public-only, so it works while locked.
1211
1102
  */
1103
+ getEthereumAddress() {
1104
+ if (!this._store.has(KeyId.ETH))
1105
+ throw new MajikKeyError("No Ethereum key — add it with addKeys([KeyId.ETH], mnemonic, passphrase).");
1106
+ return ethereumAddressFromPublicKey(this._store.getPublicKey(KeyId.ETH));
1107
+ }
1108
+ /** @experimental Raw Ethereum keypair material. Requires an unlocked account. */
1109
+ getEthereumKeypairMaterial() {
1110
+ return {
1111
+ privateKey: this._requireSecret(KeyId.ETH),
1112
+ publicKey: this._store.getPublicKey(KeyId.ETH),
1113
+ };
1114
+ }
1115
+ /** @experimental 0x-prefixed private key hex, for wallet "import private key". */
1116
+ getEthereumPrivateKeyHex() {
1117
+ return toEthereumPrivateKeyHex(this.getEthereumKeypairMaterial());
1118
+ }
1119
+ // ── SOLANA (EXPERIMENTAL) ───────────────────────────────────────────────────
1120
+ /** @experimental True if this MajikKey can currently produce a Solana keypair (unlocked + has Ed25519). */
1212
1121
  get hasSolanaKeypair() {
1213
- return this.isUnlocked && this._edSecretKey !== undefined;
1122
+ return this._store.peekSecretKey(KeyId.ED25519) !== undefined;
1214
1123
  }
1215
1124
  _getOrDeriveSolanaMaterial() {
1216
- if (!this._edSecretKey)
1125
+ const ed = this._store.peekSecretKey(KeyId.ED25519);
1126
+ if (!ed)
1217
1127
  throw new MajikKeyError("No Ed25519 secret key — MajikKey must be unlocked and have signing keys.");
1218
1128
  if (!this._solanaKeypairMaterial) {
1219
- this._solanaKeypairMaterial = deriveSolanaKeypairFromEdSecretKey(this._edSecretKey);
1129
+ this._solanaKeypairMaterial = deriveSolanaKeypairFromEdSecretKey(ed);
1220
1130
  }
1221
1131
  return this._solanaKeypairMaterial;
1222
1132
  }
1223
- /**
1224
- * @experimental Raw Solana keypair material (public/secret key bytes).
1225
- * Pass `{ reuseMessageKey: true }` to reuse the MajikKey's message signing
1226
- * Ed25519 key directly instead of the domain-separated derivation.
1227
- */
1133
+ /** @experimental Raw Solana keypair material. `reuseMessageKey: true` reuses the message-signing Ed25519 key. */
1228
1134
  getSolanaKeypairMaterial(options) {
1229
- if (this.isLocked)
1230
- throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
1231
- if (!this._edSecretKey)
1232
- throw new MajikKeyError("No Ed25519 secret key — re-import via importFromMnemonicBackup() first.");
1233
- if (options?.reuseMessageKey) {
1234
- return solanaMaterialFromEd25519SecretKey(this._edSecretKey);
1235
- }
1135
+ const ed = this._requireSecret(KeyId.ED25519, "No Ed25519 secret key — add it with addKeys() (requires the mnemonic).");
1136
+ if (options?.reuseMessageKey)
1137
+ return solanaMaterialFromEd25519SecretKey(ed);
1236
1138
  return this._getOrDeriveSolanaMaterial();
1237
1139
  }
1238
- /**
1239
- * @experimental Real @solana/kit Keypair instance. Lazily loads
1240
- * @solana/kit — throws a MajikKeyError with install instructions if
1241
- * it isn't present in the consuming project.
1242
- */
1140
+ /** @experimental Real @solana/kit Keypair instance (lazy-loads @solana/kit). */
1243
1141
  async getSolanaKeypair(options) {
1244
1142
  return toSolanaKeyPairSigner(this.getSolanaKeypairMaterial(options));
1245
1143
  }
1246
- /**
1247
- * @experimental Base58 Solana address. Does NOT require @solana/kit.
1248
- */
1144
+ /** @experimental Base58 Solana address. Does NOT require @solana/kit. */
1249
1145
  getSolanaAddress(options) {
1250
1146
  return solanaAddressFromPublicKey(this.getSolanaKeypairMaterial(options).publicKey);
1251
1147
  }