@majikah/majik-key 0.2.12 → 0.2.14

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,27 +1,9 @@
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
- import { generateMnemonic as bip39GenerateMnemonic, validateMnemonic, } from "@scure/bip39";
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";
26
8
  import { EncryptionEngine } from "./core/crypto/encryption-engine";
27
9
  import { MajikContact } from "@majikah/majik-contact";
@@ -31,9 +13,31 @@ import { MajikKeyValidator } from "./core/validator";
31
13
  import { MajikKeyError } from "./core/error";
32
14
  import { MajikMessageIdentity } from "./core/database/system/identity";
33
15
  import { WORDLISTS } from "./core/crypto/wordlist";
34
- import { deriveSolanaKeypairFromEdSecretKey, signWithSolanaMaterial, solanaAddressFromPublicKey, solanaMaterialFromEd25519SecretKey, toSolanaAddress, toSolanaKeyPairSigner, } from "./core/web3/solana";
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,8 +65,26 @@ 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
+ */
75
+ _btcPublicKey;
76
+ /**
77
+ * @experimental
78
+ */
79
+ _btcSecretKey;
80
+ /**
81
+ * @experimental
82
+ */
83
+ _encryptedBtcSecretKey;
84
+ /**
85
+ * @experimental
86
+ */
87
+ _encryptedBtcSecretKeyBase64;
66
88
  constructor(options) {
67
89
  this._id = options.id;
68
90
  this._publicKey = options.publicKey;
@@ -89,57 +111,99 @@ export class MajikKey {
89
111
  this._encryptedMlDsaSecretKeyBase64 = options.encryptedMlDsaSecretKeyBase64;
90
112
  this._edSecretKey = options.edSecretKey;
91
113
  this._mlDsaSecretKey = options.mlDsaSecretKey;
114
+ this._btcPublicKey = options.btcPublicKey;
115
+ this._btcSecretKey = options.btcSecretKey;
116
+ this._encryptedBtcSecretKey = options.encryptedBtcSecretKey;
117
+ this._encryptedBtcSecretKeyBase64 = options.encryptedBtcSecretKeyBase64;
92
118
  this._mnemonicLanguage = options.mnemonicLanguage || "en";
93
119
  }
94
120
  // ── Getters ─────────────────────────────────────────────────────────────────
121
+ /** Account identifier. Equal to `fingerprint` for accounts created by this library. */
95
122
  get id() {
96
123
  return this._id;
97
124
  }
125
+ /** SHA-256 fingerprint of the X25519 public key. Stable identity anchor for the account. */
98
126
  get fingerprint() {
99
127
  return this._fingerprint;
100
128
  }
129
+ /** X25519 public key — native `CryptoKey` where WebCrypto supports it, otherwise a raw-bytes wrapper. Always available, even when locked. */
101
130
  get publicKey() {
102
131
  return this._publicKey;
103
132
  }
133
+ /** X25519 public key, base64-encoded. Always available, even when locked. */
104
134
  get publicKeyBase64() {
105
135
  return this._publicKeyBase64;
106
136
  }
137
+ /** Human-readable, user-editable account name. Update via `updateLabel()`. */
107
138
  get label() {
108
139
  return this._label;
109
140
  }
141
+ /** BIP-39 wordlist language this account's mnemonic was generated/validated against. */
110
142
  get mnemonicLanguage() {
111
143
  return this._mnemonicLanguage;
112
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
+ */
113
151
  get backup() {
114
152
  return this._backup;
115
153
  }
154
+ /** Account creation time. */
116
155
  get timestamp() {
117
156
  return this._timestamp;
118
157
  }
158
+ /** KDF currently protecting every `encrypted*` field on this account: `1` = legacy PBKDF2, `2` = Argon2id. */
119
159
  get kdfVersion() {
120
160
  return this._kdfVersion;
121
161
  }
162
+ /** `true` if this account is on the current KDF (Argon2id). `false` means it's still on legacy PBKDF2 — see `migrate()` or `importFromMnemonicBackup()`. */
122
163
  get isArgon2id() {
123
164
  return this._kdfVersion === KDF_VERSION.ARGON2ID;
124
165
  }
166
+ /** `true` if private key material is currently purged from memory (i.e. `lock()` was called, or `unlock()` hasn't been called yet). */
125
167
  get isLocked() {
126
168
  return this._privateKey === undefined;
127
169
  }
170
+ /** `true` if private key material is currently decrypted in memory. The inverse of `isLocked`. */
128
171
  get isUnlocked() {
129
172
  return this._privateKey !== undefined;
130
173
  }
174
+ /** ML-KEM-768 (FIPS-203) public key. Post-quantum key encapsulation. Always available, even when locked. */
131
175
  get mlKemPublicKey() {
132
176
  return this._mlKemPublicKey;
133
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. */
134
179
  get mlKemSecretKey() {
135
180
  return this._mlKemSecretKey;
136
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. */
137
183
  get hasMlKem() {
138
184
  return this._mlKemPublicKey !== undefined;
139
185
  }
186
+ /** `true` if this account is on Argon2id *and* has ML-KEM-768 keys — i.e. fully migrated, nothing left to upgrade. */
140
187
  get isFullyUpgraded() {
141
188
  return this.isArgon2id && this.hasMlKem;
142
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
+ */
195
+ get btcPublicKey() {
196
+ return this._btcPublicKey;
197
+ }
198
+ /** @experimental `true` if this account has a stored Bitcoin keypair. */
199
+ get hasBitcoin() {
200
+ return this._btcPublicKey !== undefined;
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
+ */
143
207
  get metadata() {
144
208
  return {
145
209
  id: this.id,
@@ -149,29 +213,57 @@ export class MajikKey {
149
213
  isLocked: this.isLocked,
150
214
  kdfVersion: this.kdfVersion,
151
215
  hasMlKem: this.hasMlKem,
216
+ web3: {
217
+ hasBitcoin: this.hasBitcoin,
218
+ hasSolana: this.hasSolanaKeypair,
219
+ },
152
220
  mnemonicLanguage: this.mnemonicLanguage || "en",
153
221
  };
154
222
  }
223
+ /** Ed25519 public key. Classical signing — same keypair the X25519 identity key is converted from. Always available, even when locked. */
155
224
  get edPublicKey() {
156
225
  return this._edPublicKey;
157
226
  }
227
+ /** ML-DSA-87 (FIPS-204) public key. Post-quantum signing. Always available, even when locked. */
158
228
  get mlDsaPublicKey() {
159
229
  return this._mlDsaPublicKey;
160
230
  }
231
+ /** `true` if this account has both Ed25519 and ML-DSA-87 signing keys. `false` means it's a legacy account pending migration. */
161
232
  get hasSigningKeys() {
162
233
  return (this._edPublicKey !== undefined && this._mlDsaPublicKey !== undefined);
163
234
  }
164
235
  // ── CREATE ──────────────────────────────────────────────────────────────────
165
- 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
+ }) {
166
257
  try {
167
258
  MajikKeyValidator.validateMnemonic(mnemonic);
168
259
  MajikKeyValidator.validatePassphrase(passphrase);
169
260
  MajikKeyValidator.validateLabel(label);
170
- const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
261
+ const { deriveBitcoin, mnemonicLanguage } = options;
262
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage || "en");
171
263
  if (!validateMnemonic(mnemonic, wordlist)) {
172
264
  throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
173
265
  }
174
- const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase);
266
+ const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase, { deriveBitcoin: deriveBitcoin });
175
267
  const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
176
268
  const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
177
269
  const backup = await MajikKey._exportMnemonicBackup(identity, mnemonic);
@@ -201,6 +293,12 @@ export class MajikKey {
201
293
  encryptedMlDsaSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlDsaSecretKey),
202
294
  edSecretKey: identity.edSecretKey,
203
295
  mlDsaSecretKey: identity.mlDsaSecretKey,
296
+ btcPublicKey: identity.btcPublicKey,
297
+ encryptedBtcSecretKey: identity.encryptedBtcSecretKey,
298
+ encryptedBtcSecretKeyBase64: identity.encryptedBtcSecretKey
299
+ ? arrayBufferToBase64(identity.encryptedBtcSecretKey)
300
+ : undefined,
301
+ btcSecretKey: identity.btcSecretKey,
204
302
  mnemonicLanguage: mnemonicLanguage,
205
303
  });
206
304
  }
@@ -243,6 +341,15 @@ export class MajikKey {
243
341
  encryptedMlDsaSecretKeyBase64 = anyParsed.encryptedMlDsaSecretKey;
244
342
  encryptedMlDsaSecretKey = base64ToArrayBuffer(anyParsed.encryptedMlDsaSecretKey);
245
343
  }
344
+ const btcPublicKey = anyParsed.btcPublicKey
345
+ ? base64ToUint8Array(anyParsed.btcPublicKey)
346
+ : undefined;
347
+ let encryptedBtcSecretKey;
348
+ let encryptedBtcSecretKeyBase64;
349
+ if (anyParsed.encryptedBtcSecretKey) {
350
+ encryptedBtcSecretKeyBase64 = anyParsed.encryptedBtcSecretKey;
351
+ encryptedBtcSecretKey = base64ToArrayBuffer(anyParsed.encryptedBtcSecretKey);
352
+ }
246
353
  return new MajikKey({
247
354
  id: validated.id,
248
355
  publicKey: { raw: new Uint8Array(publicKeyBuffer) },
@@ -265,6 +372,9 @@ export class MajikKey {
265
372
  mlDsaPublicKey,
266
373
  encryptedMlDsaSecretKey,
267
374
  encryptedMlDsaSecretKeyBase64,
375
+ btcPublicKey,
376
+ encryptedBtcSecretKey,
377
+ encryptedBtcSecretKeyBase64,
268
378
  mnemonicLanguage: validated?.mnemonicLanguage || "en",
269
379
  });
270
380
  }
@@ -294,6 +404,9 @@ export class MajikKey {
294
404
  mlKemSecretKeyBase64: arrayToBase64(this._mlKemSecretKey),
295
405
  edSecretKeyBase64: arrayToBase64(this._edSecretKey),
296
406
  mlDsaSecretKeyBase64: arrayToBase64(this._mlDsaSecretKey),
407
+ btcSecretKeyBase64: this._btcSecretKey
408
+ ? arrayToBase64(this._btcSecretKey)
409
+ : undefined,
297
410
  };
298
411
  }
299
412
  /**
@@ -323,6 +436,12 @@ export class MajikKey {
323
436
  const mlDsaSecretKey = base64ToUint8Array(parsed.mlDsaSecretKeyBase64);
324
437
  const mlKemPublicKey = base64ToUint8Array(parsed.mlKemPublicKey);
325
438
  const mlKemSecretKey = base64ToUint8Array(parsed.mlKemSecretKeyBase64);
439
+ const btcPublicKey = parsed.btcPublicKey
440
+ ? base64ToUint8Array(parsed.btcPublicKey)
441
+ : undefined;
442
+ const btcSecretKey = parsed.btcSecretKeyBase64
443
+ ? base64ToUint8Array(parsed.btcSecretKeyBase64)
444
+ : undefined;
326
445
  return new MajikKey({
327
446
  id: parsed.id,
328
447
  fingerprint: parsed.fingerprint,
@@ -341,6 +460,8 @@ export class MajikKey {
341
460
  edSecretKey,
342
461
  mlDsaPublicKey,
343
462
  mlDsaSecretKey,
463
+ btcPublicKey,
464
+ btcSecretKey,
344
465
  });
345
466
  }
346
467
  catch (err) {
@@ -421,6 +542,13 @@ export class MajikKey {
421
542
  this._encryptedMlDsaSecretKeyBase64 = arrayBufferToBase64(encDsa);
422
543
  this._mlDsaSecretKey = mlDsaSecretKeyBytes;
423
544
  }
545
+ if (this._encryptedBtcSecretKey) {
546
+ const btcSecretKeyBytes = await MajikKey._decryptSigningKey(this._encryptedBtcSecretKey, currentPassphrase, salt);
547
+ const encBtc = await MajikKey._encryptSigningKey(btcSecretKeyBytes, newPassphrase, newSalt);
548
+ this._encryptedBtcSecretKey = encBtc;
549
+ this._encryptedBtcSecretKeyBase64 = arrayBufferToBase64(encBtc);
550
+ this._btcSecretKey = btcSecretKeyBytes;
551
+ }
424
552
  return this;
425
553
  }
426
554
  catch (err) {
@@ -462,6 +590,7 @@ export class MajikKey {
462
590
  this._mlKemSecretKey = undefined;
463
591
  this._edSecretKey = undefined;
464
592
  this._mlDsaSecretKey = undefined;
593
+ this._btcSecretKey = undefined;
465
594
  this._solanaKeypairMaterial = undefined;
466
595
  return this;
467
596
  }
@@ -493,6 +622,9 @@ export class MajikKey {
493
622
  if (this._encryptedMlDsaSecretKey) {
494
623
  this._mlDsaSecretKey = await MajikKey._decryptSigningKey(this._encryptedMlDsaSecretKey, passphrase, salt);
495
624
  }
625
+ if (this._encryptedBtcSecretKey) {
626
+ this._btcSecretKey = await MajikKey._decryptSigningKey(this._encryptedBtcSecretKey, passphrase, salt);
627
+ }
496
628
  return this;
497
629
  }
498
630
  catch (err) {
@@ -566,6 +698,10 @@ export class MajikKey {
566
698
  ? arrayToBase64(this._mlDsaPublicKey)
567
699
  : undefined,
568
700
  encryptedMlDsaSecretKey: this._encryptedMlDsaSecretKeyBase64,
701
+ btcPublicKey: this._btcPublicKey
702
+ ? arrayToBase64(this._btcPublicKey)
703
+ : undefined,
704
+ encryptedBtcSecretKey: this._encryptedBtcSecretKeyBase64,
569
705
  mnemonicLanguage: this._mnemonicLanguage,
570
706
  };
571
707
  }
@@ -660,23 +796,19 @@ export class MajikKey {
660
796
  /**
661
797
  * Import a MajikKey from a mnemonic-encrypted backup.
662
798
  *
663
- * This is the FULL MIGRATION PATH for old accounts — Argon2id + ML-KEM in one step:
664
- * 1. Verify the backup decrypts correctly (proves mnemonic is correct)
665
- * 2. Re-derive the complete identity from the mnemonic (X25519 + ML-KEM-768)
666
- * 3. Encrypt both private keys with Argon2id (v2) + fresh 32-byte salt
667
- * 4. Return a fully-upgraded MajikKey with hasMlKem: true, isArgon2id: true
668
- *
669
- * Old accounts without ML-KEM keys become fully post-quantum capable
670
- * automatically — no extra user steps. The mnemonic is the source of truth.
671
799
  */
672
- 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
+ }) {
673
804
  try {
674
805
  if (!backup || typeof backup !== "string")
675
806
  throw new MajikKeyError("Backup must be a non-empty string");
676
807
  MajikKeyValidator.validateMnemonic(mnemonic);
677
808
  MajikKeyValidator.validatePassphrase(passphrase);
678
809
  MajikKeyValidator.validateLabel(label);
679
- const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
810
+ const { deriveBitcoin, mnemonicLanguage } = options;
811
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage || "en");
680
812
  if (!validateMnemonic(mnemonic, wordlist)) {
681
813
  throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
682
814
  }
@@ -693,7 +825,7 @@ export class MajikKey {
693
825
  // Verify mnemonic is correct before doing expensive re-derivation
694
826
  await MajikKey._verifyBackupDecryption(parsed.iv, parsed.ciphertext, mnemonic, backupKdfVersion);
695
827
  // Re-derive complete identity from mnemonic — gets ML-KEM for free
696
- const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase);
828
+ const identity = await MajikKey._deriveAndEncryptFromMnemonic(mnemonic, passphrase, { deriveBitcoin: deriveBitcoin });
697
829
  const privateKeyBase64 = await MajikKey._exportKeyToBase64(identity.privateKey);
698
830
  const publicKeyBase64 = await MajikKey._exportKeyToBase64(identity.publicKey);
699
831
  const id = parsed.id || identity.id;
@@ -723,6 +855,12 @@ export class MajikKey {
723
855
  encryptedMlDsaSecretKeyBase64: arrayBufferToBase64(identity.encryptedMlDsaSecretKey),
724
856
  edSecretKey: identity.edSecretKey,
725
857
  mlDsaSecretKey: identity.mlDsaSecretKey,
858
+ btcPublicKey: identity.btcPublicKey,
859
+ encryptedBtcSecretKey: identity.encryptedBtcSecretKey,
860
+ encryptedBtcSecretKeyBase64: identity.encryptedBtcSecretKey
861
+ ? arrayBufferToBase64(identity.encryptedBtcSecretKey)
862
+ : undefined,
863
+ btcSecretKey: identity.btcSecretKey,
726
864
  });
727
865
  }
728
866
  catch (err) {
@@ -737,7 +875,13 @@ export class MajikKey {
737
875
  const mod = await loader();
738
876
  return mod.wordlist;
739
877
  }
740
- 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;
741
885
  const encIdentity = await EncryptionEngine.deriveIdentityFromMnemonic(mnemonic);
742
886
  let exportedXPrivate;
743
887
  try {
@@ -752,16 +896,30 @@ export class MajikKey {
752
896
  throw new MajikKeyError("Cannot export private key: unsupported format");
753
897
  }
754
898
  }
755
- // Single salt — one Argon2id derivation unlocks both keys
899
+ // Single salt — one Argon2id derivation unlocks every key below
756
900
  const salt = generateRandomBytes(SALT_SIZE);
757
901
  const { blob: encryptedPrivateKey } = await MajikKey._encryptPrivateKey(exportedXPrivate, passphrase, salt);
758
902
  const mlKemSecretKey = encIdentity.mlKemSecretKey;
759
903
  const encryptedMlKemSecretKey = await MajikKey._encryptMlKemSecretKey(mlKemSecretKey, passphrase, salt);
760
- // after the existing ML-KEM encryption block:
761
904
  const edSecretKey = encIdentity.edSecretKey;
762
905
  const encryptedEdSecretKey = await MajikKey._encryptSigningKey(edSecretKey, passphrase, salt);
763
906
  const mlDsaSecretKey = encIdentity.mlDsaSecretKey;
764
907
  const encryptedMlDsaSecretKey = await MajikKey._encryptSigningKey(mlDsaSecretKey, 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
+ }
765
923
  return {
766
924
  id: encIdentity.fingerprint,
767
925
  publicKey: encIdentity.publicKey,
@@ -779,6 +937,9 @@ export class MajikKey {
779
937
  mlDsaPublicKey: encIdentity.mlDsaPublicKey,
780
938
  mlDsaSecretKey,
781
939
  encryptedMlDsaSecretKey,
940
+ btcPublicKey,
941
+ btcSecretKey,
942
+ encryptedBtcSecretKey,
782
943
  };
783
944
  }
784
945
  // ── PRIVATE: Encryption/Decryption ───────────────────────────────────────────
@@ -912,30 +1073,95 @@ export class MajikKey {
912
1073
  }
913
1074
  // ── WEB3 (EXPERIMENTAL) ─────────────────────────────────────────────────────
914
1075
  /**
915
- * @experimental Blockchain/web3 integrations are experimental — this API
916
- * may change without notice. `undefined` when the MajikKey is locked or
917
- * has no Ed25519 signing key to derive from.
918
- *
919
- * By default `web3.solana` is DOMAIN-SEPARATED from the MajikKey's message
920
- * signing key (see deriveSolanaKeypairFromEdSecretKey). Use
921
- * `getSolanaKeypairMaterial({ reuseMessageKey: true })` if you specifically
922
- * want the identical key reused for Solana instead.
1076
+ * @experimental
1077
+ */
1078
+ getBtcSecretKey() {
1079
+ if (this.isLocked)
1080
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
1081
+ if (!this._btcSecretKey)
1082
+ throw new MajikKeyError("No Bitcoin secret key — re-import via importFromMnemonicBackup() for full migration.");
1083
+ return this._btcSecretKey;
1084
+ }
1085
+ // ── WEB3 (EXPERIMENTAL) — updated getter ────────────────────────────────────
1086
+ /**
1087
+ * @experimental
923
1088
  */
924
1089
  get web3() {
925
1090
  if (!this.hasSolanaKeypair)
926
1091
  return undefined;
927
- const material = this._getOrDeriveSolanaMaterial();
1092
+ const solanaMaterial = this._getOrDeriveSolanaMaterial();
1093
+ const btcMaterial = this._btcSecretKey && this._btcPublicKey
1094
+ ? { privateKey: this._btcSecretKey, publicKey: this._btcPublicKey }
1095
+ : undefined;
928
1096
  return {
929
1097
  solana: {
930
- publicKey: material.publicKey,
931
- secretKey: material.secretKey,
932
- address: solanaAddressFromPublicKey(material.publicKey),
933
- getSolanaKeypair: () => toSolanaKeyPairSigner(material),
934
- getSolanaAddress: () => toSolanaAddress(material),
935
- sign: (message) => signWithSolanaMaterial(material, message),
1098
+ publicKey: solanaMaterial.publicKey,
1099
+ secretKey: solanaMaterial.secretKey,
1100
+ address: solanaAddressFromPublicKey(solanaMaterial.publicKey),
1101
+ getSolanaKeypair: () => toSolanaKeyPairSigner(solanaMaterial),
1102
+ getSolanaAddress: () => toSolanaAddress(solanaMaterial),
1103
+ sign: (message) => signWithSolanaMaterial(solanaMaterial, message),
1104
+ },
1105
+ bitcoin: btcMaterial && {
1106
+ publicKey: btcMaterial.publicKey,
1107
+ privateKey: btcMaterial.privateKey,
1108
+ getBitcoinAddress: () => toBitcoinAddress(btcMaterial),
1109
+ getWIF: (options) => toWIF(btcMaterial, options),
1110
+ sign: (hash, scheme) => signWithBitcoinMaterial(btcMaterial, hash, scheme),
936
1111
  },
937
1112
  };
938
1113
  }
1114
+ // ── BITCON (EXPERIMENTAL) ────────────────────────────────────
1115
+ /**
1116
+ * @experimental True if this MajikKey can currently produce Bitcoin
1117
+ * material (i.e. it's unlocked and has a Bitcoin secret key).
1118
+ */
1119
+ get hasBitcoinKeypair() {
1120
+ return this.isUnlocked && this._btcSecretKey !== undefined;
1121
+ }
1122
+ /**
1123
+ * @experimental Raw Bitcoin keypair material. Pass `{ standard: true }` to
1124
+ * get the REAL BIP-84 mainnet key (recoverable in any standard wallet from
1125
+ * the mnemonic alone) instead of Majik's default domain-separated key.
1126
+ *
1127
+ * NOTE: `{ standard: true }` re-derives from the raw seed on demand and is
1128
+ * NOT the same key as `web3.bitcoin` (which is always the stored,
1129
+ * domain-separated default) — it requires the mnemonic to reproduce again
1130
+ * outside Majik, whereas the stored default does not.
1131
+ */
1132
+ getBitcoinKeypairMaterial(options) {
1133
+ if (this.isLocked)
1134
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
1135
+ if (!options?.standard && !options?.path) {
1136
+ if (!this._btcSecretKey || !this._btcPublicKey)
1137
+ throw new MajikKeyError("No Bitcoin secret key — re-import via importFromMnemonicBackup() first.");
1138
+ return { privateKey: this._btcSecretKey, publicKey: this._btcPublicKey };
1139
+ }
1140
+ throw new MajikKeyError("Deriving the standard BIP-84 path requires the mnemonic — " +
1141
+ "use MajikKey.deriveStandardBitcoinFromMnemonic(mnemonic) instead.");
1142
+ }
1143
+ /**
1144
+ * @experimental Derive the REAL BIP-84 mainnet Bitcoin keypair straight
1145
+ * from a mnemonic — for one-off export/verification. Does not require
1146
+ * an unlocked MajikKey instance.
1147
+ */
1148
+ static async deriveStandardBitcoinFromMnemonic(mnemonic, mnemonicLanguage = "en") {
1149
+ MajikKeyValidator.validateMnemonic(mnemonic);
1150
+ const wordlist = await MajikKey._getWordlist(mnemonicLanguage);
1151
+ if (!validateMnemonic(mnemonic, wordlist)) {
1152
+ throw new MajikKeyError("Invalid BIP39 mnemonic phrase");
1153
+ }
1154
+ const seed = await mnemonicToSeed(mnemonic);
1155
+ return deriveBitcoinKeypairFromSeed(seed, { standard: true });
1156
+ }
1157
+ /**
1158
+ * @experimental WIF export of the default (domain-separated) Bitcoin key.
1159
+ */
1160
+ getBitcoinWIF(options) {
1161
+ const material = this.getBitcoinKeypairMaterial();
1162
+ return toWIF(material, options);
1163
+ }
1164
+ // ── SOLANA (EXPERIMENTAL) ────────────────────────────────────
939
1165
  /**
940
1166
  * @experimental True if this MajikKey can currently produce a Solana
941
1167
  * keypair (i.e. it's unlocked and has an Ed25519 signing key).
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.12",
5
+ "version": "0.2.14",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",
@@ -47,12 +47,18 @@
47
47
  "prepublishOnly": "npm run build",
48
48
  "package": "npm run build && npm version patch && git push && git push --tags",
49
49
  "test": "vitest run",
50
- "test:watch": "vitest"
50
+ "test:watch": "vitest",
51
+ "test:web3:bitcoin": "npx vitest test/majik-key-bitcoin.test.ts",
52
+ "test:web3:solana": "npx vitest test/majik-key-solana.test.ts",
53
+ "test:web3": "npx vitest test/majik-key-bitcoin.test.ts test/majik-key-solana.test.ts",
54
+ "test:core": "npx vitest test/majik-key.test.ts"
51
55
  },
52
56
  "dependencies": {
53
57
  "@majikah/majik-contact": "^0.0.6",
58
+ "@noble/curves": "^2.2.0",
54
59
  "@noble/hashes": "^2.2.0",
55
60
  "@noble/post-quantum": "^0.6.1",
61
+ "@scure/bip32": "^2.2.0",
56
62
  "@scure/bip39": "^2.2.0",
57
63
  "@stablelib/aes": "^2.0.1",
58
64
  "@stablelib/ed25519": "^2.1.0",
@@ -65,6 +71,7 @@
65
71
  "hash-wasm": "^4.12.0"
66
72
  },
67
73
  "devDependencies": {
74
+ "@scure/btc-signer": "^2.2.0",
68
75
  "@solana/kit": "^7.0.0",
69
76
  "@types/ed2curve": "^0.2.4",
70
77
  "@types/node": "^26.1.0",
@@ -72,11 +79,15 @@
72
79
  "vitest": "^4.1.9"
73
80
  },
74
81
  "peerDependencies": {
82
+ "@scure/btc-signer": "^2.2.0",
75
83
  "@solana/kit": "^7.0.0"
76
84
  },
77
85
  "peerDependenciesMeta": {
78
86
  "@solana/kit": {
79
87
  "optional": true
88
+ },
89
+ "@scure/btc-signer": {
90
+ "optional": true
80
91
  }
81
92
  }
82
93
  }