@majikah/majik-key 0.2.13 → 0.3.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.
package/dist/majik-key.js CHANGED
@@ -1,25 +1,7 @@
1
1
  /**
2
2
  * MajikKey.ts
3
+ * Seed phrase account library for the Majikah ecosystem.
3
4
  *
4
- * Seed phrase account library for Majik Message.
5
- *
6
- * Every account stores TWO keypairs derived deterministically from the mnemonic:
7
- * 1. X25519 (Curve25519) — fingerprint, contact identity, legacy message compat
8
- * 2. ML-KEM-768 (FIPS-203) — post-quantum key encapsulation for v3 envelopes
9
- *
10
- * Both are derived from the 64-byte BIP-39 seed:
11
- * seed[0..32] → Ed25519 → X25519 via ed2curve
12
- * seed[0..64] → ml_kem768.keygen(seed) — full seed, deterministic
13
- *
14
- * KDF versioning (passphrase encryption at rest):
15
- * v1 — PBKDF2-SHA256, 250k iterations (legacy read-only)
16
- * v2 — Argon2id, 128 MB / 4t / 4p (all new accounts)
17
- *
18
- * Migration policy:
19
- * Old accounts (v1, no ML-KEM keys) are fully upgraded on first import
20
- * via importFromMnemonicBackup(). The mnemonic is always available at that
21
- * point, so ML-KEM keys can be deterministically re-derived and stored.
22
- * No partial migration — either fully upgraded or not upgraded yet.
23
5
  */
24
6
  import { generateMnemonic as bip39GenerateMnemonic, mnemonicToSeed, validateMnemonic, } from "@scure/bip39";
25
7
  import { aesGcmDecrypt, aesGcmEncrypt, deriveKeyFromPassphraseArgon2, deriveKeyFromMnemonicArgon2, deriveKeyFromPassphrase, generateRandomBytes, IV_LENGTH, } from "./core/crypto/crypto-provider";
@@ -33,7 +15,29 @@ import { MajikMessageIdentity } from "./core/database/system/identity";
33
15
  import { WORDLISTS } from "./core/crypto/wordlist";
34
16
  import { deriveBitcoinKeypairFromSeed, signWithBitcoinMaterial, toBitcoinAddress, toWIF, deriveSolanaKeypairFromEdSecretKey, signWithSolanaMaterial, solanaAddressFromPublicKey, solanaMaterialFromEd25519SecretKey, toSolanaAddress, toSolanaKeyPairSigner, } from "./core/web3";
35
17
  const SALT_SIZE = 32;
36
- // ─── MajikKey ─────────────────────────────────────────────────────────────────
18
+ /**
19
+ * MajikKey
20
+ * ---
21
+ *
22
+ * Seed phrase account library for the Majikah ecosystem.
23
+ *
24
+ * Every account stores FIVE keypairs, all deterministically derived from a
25
+ * single BIP-39 mnemonic:
26
+ * 1. X25519 (Curve25519) — fingerprint, contact identity, legacy message compat
27
+ * 2. ML-KEM-768 (FIPS-203) — post-quantum key encapsulation for v3 envelopes
28
+ * 3. Ed25519 — classical signing
29
+ * 4. ML-DSA-87 (FIPS-204) — post-quantum signing
30
+ * 5. Bitcoin (secp256k1) — BIP-32/84 HD key, domain-separated by default (experimental)
31
+ *
32
+ * All derived from the 64-byte BIP-39 seed:
33
+ * seed[0..32] → Ed25519 keypair — used directly for signing, AND converted
34
+ * to X25519 via ed2curve for the encryption/identity keypair
35
+ * (one Ed25519 keypair, two roles)
36
+ * seed[0..64] → ml_kem768.keygen(seed) — full seed, deterministic
37
+ * hash(seed || "MajikSignatureSeedDSA") → 32-byte seed → ml_dsa87.keygen()
38
+ * seed[0..64] → HDKey.fromMasterSeed(seed).derive(path) — BIP-32/84 Bitcoin key
39
+ *
40
+ */
37
41
  export class MajikKey {
38
42
  _id;
39
43
  _publicKey;
@@ -61,11 +65,25 @@ export class MajikKey {
61
65
  _mlDsaSecretKey;
62
66
  _encryptedMlDsaSecretKey;
63
67
  _encryptedMlDsaSecretKeyBase64;
64
- //Experimental
68
+ /**
69
+ * @experimental
70
+ */
65
71
  _solanaKeypairMaterial;
72
+ /**
73
+ * @experimental
74
+ */
66
75
  _btcPublicKey;
76
+ /**
77
+ * @experimental
78
+ */
67
79
  _btcSecretKey;
80
+ /**
81
+ * @experimental
82
+ */
68
83
  _encryptedBtcSecretKey;
84
+ /**
85
+ * @experimental
86
+ */
69
87
  _encryptedBtcSecretKeyBase64;
70
88
  constructor(options) {
71
89
  this._id = options.id;
@@ -100,60 +118,92 @@ export class MajikKey {
100
118
  this._mnemonicLanguage = options.mnemonicLanguage || "en";
101
119
  }
102
120
  // ── Getters ─────────────────────────────────────────────────────────────────
121
+ /** Account identifier. Equal to `fingerprint` for accounts created by this library. */
103
122
  get id() {
104
123
  return this._id;
105
124
  }
125
+ /** SHA-256 fingerprint of the X25519 public key. Stable identity anchor for the account. */
106
126
  get fingerprint() {
107
127
  return this._fingerprint;
108
128
  }
129
+ /** X25519 public key — native `CryptoKey` where WebCrypto supports it, otherwise a raw-bytes wrapper. Always available, even when locked. */
109
130
  get publicKey() {
110
131
  return this._publicKey;
111
132
  }
133
+ /** X25519 public key, base64-encoded. Always available, even when locked. */
112
134
  get publicKeyBase64() {
113
135
  return this._publicKeyBase64;
114
136
  }
137
+ /** Human-readable, user-editable account name. Update via `updateLabel()`. */
115
138
  get label() {
116
139
  return this._label;
117
140
  }
141
+ /** BIP-39 wordlist language this account's mnemonic was generated/validated against. */
118
142
  get mnemonicLanguage() {
119
143
  return this._mnemonicLanguage;
120
144
  }
145
+ /**
146
+ * Encrypted mnemonic-verification blob (base64 JSON). Decryptable only
147
+ * with the original mnemonic — used internally to verify a supplied
148
+ * mnemonic before `importFromMnemonicBackup()` re-derives the full
149
+ * identity. Not a general-purpose private-key backup.
150
+ */
121
151
  get backup() {
122
152
  return this._backup;
123
153
  }
154
+ /** Account creation time. */
124
155
  get timestamp() {
125
156
  return this._timestamp;
126
157
  }
158
+ /** KDF currently protecting every `encrypted*` field on this account: `1` = legacy PBKDF2, `2` = Argon2id. */
127
159
  get kdfVersion() {
128
160
  return this._kdfVersion;
129
161
  }
162
+ /** `true` if this account is on the current KDF (Argon2id). `false` means it's still on legacy PBKDF2 — see `migrate()` or `importFromMnemonicBackup()`. */
130
163
  get isArgon2id() {
131
164
  return this._kdfVersion === KDF_VERSION.ARGON2ID;
132
165
  }
166
+ /** `true` if private key material is currently purged from memory (i.e. `lock()` was called, or `unlock()` hasn't been called yet). */
133
167
  get isLocked() {
134
168
  return this._privateKey === undefined;
135
169
  }
170
+ /** `true` if private key material is currently decrypted in memory. The inverse of `isLocked`. */
136
171
  get isUnlocked() {
137
172
  return this._privateKey !== undefined;
138
173
  }
174
+ /** ML-KEM-768 (FIPS-203) public key. Post-quantum key encapsulation. Always available, even when locked. */
139
175
  get mlKemPublicKey() {
140
176
  return this._mlKemPublicKey;
141
177
  }
178
+ /** 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. */
142
179
  get mlKemSecretKey() {
143
180
  return this._mlKemSecretKey;
144
181
  }
182
+ /** `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. */
145
183
  get hasMlKem() {
146
184
  return this._mlKemPublicKey !== undefined;
147
185
  }
186
+ /** `true` if this account is on Argon2id *and* has ML-KEM-768 keys — i.e. fully migrated, nothing left to upgrade. */
148
187
  get isFullyUpgraded() {
149
188
  return this.isArgon2id && this.hasMlKem;
150
189
  }
190
+ /**
191
+ * @experimental secp256k1 Bitcoin public key. `undefined` if this account
192
+ * has no stored Bitcoin key material (e.g. it predates Web3 support and
193
+ * hasn't been re-imported via `importFromMnemonicBackup()`).
194
+ */
151
195
  get btcPublicKey() {
152
196
  return this._btcPublicKey;
153
197
  }
198
+ /** @experimental `true` if this account has a stored Bitcoin keypair. */
154
199
  get hasBitcoin() {
155
200
  return this._btcPublicKey !== undefined;
156
201
  }
202
+ /**
203
+ * Lightweight, non-secret snapshot of this account's state — no key bytes
204
+ * at all, encrypted or otherwise. Useful for account pickers, dashboards,
205
+ * or anywhere you want to display status without touching key material.
206
+ */
157
207
  get metadata() {
158
208
  return {
159
209
  id: this.id,
@@ -170,26 +220,50 @@ export class MajikKey {
170
220
  mnemonicLanguage: this.mnemonicLanguage || "en",
171
221
  };
172
222
  }
223
+ /** Ed25519 public key. Classical signing — same keypair the X25519 identity key is converted from. Always available, even when locked. */
173
224
  get edPublicKey() {
174
225
  return this._edPublicKey;
175
226
  }
227
+ /** ML-DSA-87 (FIPS-204) public key. Post-quantum signing. Always available, even when locked. */
176
228
  get mlDsaPublicKey() {
177
229
  return this._mlDsaPublicKey;
178
230
  }
231
+ /** `true` if this account has both Ed25519 and ML-DSA-87 signing keys. `false` means it's a legacy account pending migration. */
179
232
  get hasSigningKeys() {
180
233
  return (this._edPublicKey !== undefined && this._mlDsaPublicKey !== undefined);
181
234
  }
182
235
  // ── CREATE ──────────────────────────────────────────────────────────────────
183
- static async create(mnemonic, passphrase, label, mnemonicLanguage = "en") {
236
+ /**
237
+ * Creates a brand-new MajikKey account from a BIP-39 mnemonic.
238
+ *
239
+ * Derives the full key set in one pass — X25519, ML-KEM-768, Ed25519,
240
+ * ML-DSA-87, and a domain-separated Bitcoin key (see `MAJIK_BITCOIN_DOMAIN_PATH`)
241
+ * — encrypts every private key with Argon2id (KDF v2), and returns an
242
+ * **already-unlocked** instance (no `unlock()` call needed right after
243
+ * `create()`).
244
+ *
245
+ * @param mnemonic - A valid BIP-39 mnemonic phrase (12 or 24 words), matching `mnemonicLanguage`. Generate one with `MajikKey.generateMnemonic()`.
246
+ * @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.
247
+ * @param label - Optional human-readable account name. Defaults to an empty string. Update later via `updateLabel()`.
248
+ * @param mnemonicLanguage - BIP-39 wordlist to validate `mnemonic` against. Defaults to `"en"`.
249
+ * @param options.deriveBitcoin - @experimental Set `false` to skip deriving the Bitcoin keypair. Defaults to `true`.
250
+ * @returns An unlocked `MajikKey` instance, ready for immediate use — call `.lock()` when you're done with it.
251
+ * @throws {MajikKeyError} If `mnemonic` fails validation, `passphrase`/`label` fail their validators, or `mnemonic` doesn't match `mnemonicLanguage`'s wordlist.
252
+ */
253
+ static async create(mnemonic, passphrase, label, options = {
254
+ deriveBitcoin: true,
255
+ mnemonicLanguage: "en",
256
+ }) {
184
257
  try {
185
258
  MajikKeyValidator.validateMnemonic(mnemonic);
186
259
  MajikKeyValidator.validatePassphrase(passphrase);
187
260
  MajikKeyValidator.validateLabel(label);
188
- const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
261
+ const { deriveBitcoin, mnemonicLanguage } = options;
262
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage || "en");
189
263
  if (!validateMnemonic(mnemonic, wordlist)) {
190
264
  throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
191
265
  }
192
- const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase);
266
+ const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase, { deriveBitcoin: deriveBitcoin });
193
267
  const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
194
268
  const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
195
269
  const backup = await MajikKey._exportMnemonicBackup(identity, mnemonic);
@@ -221,7 +295,9 @@ export class MajikKey {
221
295
  mlDsaSecretKey: identity.mlDsaSecretKey,
222
296
  btcPublicKey: identity.btcPublicKey,
223
297
  encryptedBtcSecretKey: identity.encryptedBtcSecretKey,
224
- encryptedBtcSecretKeyBase64: arrayBufferToBase64(identity.encryptedBtcSecretKey),
298
+ encryptedBtcSecretKeyBase64: identity.encryptedBtcSecretKey
299
+ ? arrayBufferToBase64(identity.encryptedBtcSecretKey)
300
+ : undefined,
225
301
  btcSecretKey: identity.btcSecretKey,
226
302
  mnemonicLanguage: mnemonicLanguage,
227
303
  });
@@ -720,23 +796,19 @@ export class MajikKey {
720
796
  /**
721
797
  * Import a MajikKey from a mnemonic-encrypted backup.
722
798
  *
723
- * This is the FULL MIGRATION PATH for old accounts — Argon2id + ML-KEM in one step:
724
- * 1. Verify the backup decrypts correctly (proves mnemonic is correct)
725
- * 2. Re-derive the complete identity from the mnemonic (X25519 + ML-KEM-768)
726
- * 3. Encrypt both private keys with Argon2id (v2) + fresh 32-byte salt
727
- * 4. Return a fully-upgraded MajikKey with hasMlKem: true, isArgon2id: true
728
- *
729
- * Old accounts without ML-KEM keys become fully post-quantum capable
730
- * automatically — no extra user steps. The mnemonic is the source of truth.
731
799
  */
732
- static async importFromMnemonicBackup(backup, mnemonic, passphrase, label, mnemonicLanguage = "en") {
800
+ static async importFromMnemonicBackup(backup, mnemonic, passphrase, label, options = {
801
+ deriveBitcoin: true,
802
+ mnemonicLanguage: "en",
803
+ }) {
733
804
  try {
734
805
  if (!backup || typeof backup !== "string")
735
806
  throw new MajikKeyError("Backup must be a non-empty string");
736
807
  MajikKeyValidator.validateMnemonic(mnemonic);
737
808
  MajikKeyValidator.validatePassphrase(passphrase);
738
809
  MajikKeyValidator.validateLabel(label);
739
- const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
810
+ const { deriveBitcoin, mnemonicLanguage } = options;
811
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage || "en");
740
812
  if (!validateMnemonic(mnemonic, wordlist)) {
741
813
  throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
742
814
  }
@@ -753,7 +825,7 @@ export class MajikKey {
753
825
  // Verify mnemonic is correct before doing expensive re-derivation
754
826
  await MajikKey._verifyBackupDecryption(parsed.iv, parsed.ciphertext, mnemonic, backupKdfVersion);
755
827
  // Re-derive complete identity from mnemonic — gets ML-KEM for free
756
- const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase);
828
+ const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase, { deriveBitcoin: deriveBitcoin });
757
829
  const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
758
830
  const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
759
831
  const id = parsed.id || identity.id;
@@ -785,7 +857,9 @@ export class MajikKey {
785
857
  mlDsaSecretKey: identity.mlDsaSecretKey,
786
858
  btcPublicKey: identity.btcPublicKey,
787
859
  encryptedBtcSecretKey: identity.encryptedBtcSecretKey,
788
- encryptedBtcSecretKeyBase64: arrayBufferToBase64(identity.encryptedBtcSecretKey),
860
+ encryptedBtcSecretKeyBase64: identity.encryptedBtcSecretKey
861
+ ? arrayBufferToBase64(identity.encryptedBtcSecretKey)
862
+ : undefined,
789
863
  btcSecretKey: identity.btcSecretKey,
790
864
  });
791
865
  }
@@ -801,7 +875,13 @@ export class MajikKey {
801
875
  const mod = await loader();
802
876
  return mod.wordlist;
803
877
  }
804
- static async _deriveAndEncryptFromMnemonic(mnemonic, passphrase) {
878
+ /**
879
+ * @param mnemonic - BIP-39 mnemonic to derive the full key set from.
880
+ * @param passphrase - Passphrase used to derive the Argon2id encryption key shared by every private key produced here.
881
+ * @param options.deriveBitcoin - @experimental Set `false` to skip deriving the Bitcoin keypair. Defaults to `true`.
882
+ */
883
+ static async _deriveAndEncryptFromMnemonic(mnemonic, passphrase, options) {
884
+ const deriveBitcoin = options?.deriveBitcoin ?? true;
805
885
  const encIdentity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
806
886
  let exportedXPrivate;
807
887
  try {
@@ -816,22 +896,30 @@ export class MajikKey {
816
896
  throw new MajikKeyError("Cannot export private key: unsupported format");
817
897
  }
818
898
  }
819
- // Single salt — one Argon2id derivation unlocks both keys
899
+ // Single salt — one Argon2id derivation unlocks every key below
820
900
  const salt = generateRandomBytes(SALT_SIZE);
821
901
  const { blob: encryptedPrivateKey } = await MajikKey._encryptPrivateKey(exportedXPrivate, passphrase, salt);
822
902
  const mlKemSecretKey = encIdentity.mlKemSecretKey;
823
903
  const encryptedMlKemSecretKey = await MajikKey._encryptMlKemSecretKey(mlKemSecretKey, passphrase, salt);
824
- // after the existing ML-KEM encryption block:
825
904
  const edSecretKey = encIdentity.edSecretKey;
826
905
  const encryptedEdSecretKey = await MajikKey._encryptSigningKey(edSecretKey, passphrase, salt);
827
906
  const mlDsaSecretKey = encIdentity.mlDsaSecretKey;
828
907
  const encryptedMlDsaSecretKey = await MajikKey._encryptSigningKey(mlDsaSecretKey, passphrase, salt);
829
- // Bitcoin — real BIP-32/BIP-84 off the raw 64-byte BIP-39 seed, using
830
- // Majik's domain-separated path by default. Same salt, different IV,
831
- // same pattern as ML-KEM/Ed25519/ML-DSA above.
832
- const rawSeed = await mnemonicToSeed(mnemonic);
833
- const btcMaterial = deriveBitcoinKeypairFromSeed(rawSeed);
834
- const encryptedBtcSecretKey = await MajikKey._encryptSigningKey(btcMaterial.privateKey, passphrase, salt);
908
+ // @experimental Bitcoin — real BIP-32/BIP-84 off the raw 64-byte BIP-39
909
+ // seed, using Majik's domain-separated path by default. Same salt,
910
+ // different IV, same pattern as ML-KEM/Ed25519/ML-DSA above. Skipped
911
+ // entirely when `deriveBitcoin` is false — no derivation cost paid,
912
+ // no key material generated.
913
+ let btcPublicKey;
914
+ let btcSecretKey;
915
+ let encryptedBtcSecretKey;
916
+ if (deriveBitcoin) {
917
+ const rawSeed = await mnemonicToSeed(mnemonic);
918
+ const btcMaterial = deriveBitcoinKeypairFromSeed(rawSeed);
919
+ btcPublicKey = btcMaterial.publicKey;
920
+ btcSecretKey = btcMaterial.privateKey;
921
+ encryptedBtcSecretKey = await MajikKey._encryptSigningKey(btcMaterial.privateKey, passphrase, salt);
922
+ }
835
923
  return {
836
924
  id: encIdentity.fingerprint,
837
925
  publicKey: encIdentity.publicKey,
@@ -849,8 +937,8 @@ export class MajikKey {
849
937
  mlDsaPublicKey: encIdentity.mlDsaPublicKey,
850
938
  mlDsaSecretKey,
851
939
  encryptedMlDsaSecretKey,
852
- btcPublicKey: btcMaterial.publicKey,
853
- btcSecretKey: btcMaterial.privateKey,
940
+ btcPublicKey,
941
+ btcSecretKey,
854
942
  encryptedBtcSecretKey,
855
943
  };
856
944
  }
@@ -984,6 +1072,9 @@ export class MajikKey {
984
1072
  return arrayBufferToBase64(raw);
985
1073
  }
986
1074
  // ── WEB3 (EXPERIMENTAL) ─────────────────────────────────────────────────────
1075
+ /**
1076
+ * @experimental
1077
+ */
987
1078
  getBtcSecretKey() {
988
1079
  if (this.isLocked)
989
1080
  throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
@@ -992,6 +1083,9 @@ export class MajikKey {
992
1083
  return this._btcSecretKey;
993
1084
  }
994
1085
  // ── WEB3 (EXPERIMENTAL) — updated getter ────────────────────────────────────
1086
+ /**
1087
+ * @experimental
1088
+ */
995
1089
  get web3() {
996
1090
  if (!this.hasSolanaKeypair)
997
1091
  return undefined;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@majikah/majik-key",
3
3
  "type": "module",
4
4
  "description": "A post-quantum ready seed phrase account library for the Majikah ecosystem. Manages deterministic X25519 and ML-KEM-768 identities with Argon2id protection and seamless legacy account migration.",
5
- "version": "0.2.13",
5
+ "version": "0.3.0",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",