@majikah/majik-key 0.7.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +566 -175
- package/dist/core/backup/index.d.ts +4 -4
- package/dist/core/backup/index.js +3 -3
- package/dist/core/backup/majik-key-backup.d.ts +3 -3
- package/dist/core/backup/majik-key-backup.js +5 -5
- package/dist/core/backup/types.d.ts +1 -1
- package/dist/core/backup/utils.js +1 -1
- package/dist/core/backup/validator.d.ts +1 -1
- package/dist/core/backup/validator.js +1 -1
- package/dist/core/crypto/constants.d.ts +35 -11
- package/dist/core/crypto/constants.js +33 -11
- package/dist/core/crypto/crypto-provider.js +2 -2
- package/dist/core/crypto/encryption-engine.d.ts +7 -16
- package/dist/core/crypto/encryption-engine.js +27 -62
- package/dist/core/database/system/identity.d.ts +2 -2
- package/dist/core/database/system/identity.js +1 -1
- package/dist/core/keys/hkdf-recipe.d.ts +4 -0
- package/dist/core/keys/hkdf-recipe.js +26 -0
- package/dist/core/keys/key-id.d.ts +50 -0
- package/dist/core/keys/key-id.js +64 -0
- package/dist/core/keys/key-impls.d.ts +12 -0
- package/dist/core/keys/key-impls.js +163 -0
- package/dist/core/keys/key-store.d.ts +72 -0
- package/dist/core/keys/key-store.js +264 -0
- package/dist/core/keys/keypair-handle.d.ts +36 -0
- package/dist/core/keys/keypair-handle.js +43 -0
- package/dist/core/keys/registry.d.ts +16 -0
- package/dist/core/keys/registry.js +144 -0
- package/dist/core/keys/types.d.ts +47 -0
- package/dist/core/keys/types.js +1 -0
- package/dist/core/types.d.ts +12 -1
- package/dist/core/utils.d.ts +1 -1
- package/dist/core/utils.js +1 -1
- package/dist/core/validator.d.ts +1 -1
- package/dist/core/validator.js +1 -1
- package/dist/core/web3/bitcoin/bitcoin.d.ts +1 -1
- package/dist/core/web3/bitcoin/bitcoin.js +3 -3
- package/dist/core/web3/bitcoin/types.d.ts +1 -1
- package/dist/core/web3/ethereum/constants.d.ts +1 -0
- package/dist/core/web3/ethereum/constants.js +4 -0
- package/dist/core/web3/ethereum/ethereum.d.ts +21 -0
- package/dist/core/web3/ethereum/ethereum.js +91 -0
- package/dist/core/web3/ethereum/types.d.ts +32 -0
- package/dist/core/web3/ethereum/types.js +1 -0
- package/dist/core/web3/index.d.ts +10 -5
- package/dist/core/web3/index.js +7 -2
- package/dist/core/web3/solana/solana.d.ts +2 -2
- package/dist/core/web3/solana/solana.js +4 -4
- package/dist/core/web3/solana/types.d.ts +1 -1
- package/dist/core/web3/types.d.ts +4 -2
- package/dist/index.d.ts +16 -6
- package/dist/index.js +14 -5
- package/dist/majik-key.d.ts +179 -281
- package/dist/majik-key.js +622 -726
- 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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
|
|
51
|
-
_encryptedPrivateKeyBase64;
|
|
51
|
+
_store;
|
|
52
52
|
_salt;
|
|
53
53
|
_label;
|
|
54
54
|
_kdfVersion;
|
|
55
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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.
|
|
107
|
+
return this._store.isUnlocked;
|
|
172
108
|
}
|
|
173
|
-
/**
|
|
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.
|
|
207
|
+
return (this._store.has(KeyId.ML_KEM_768)
|
|
208
|
+
? this._store.getPublicKey(KeyId.ML_KEM_768)
|
|
209
|
+
: undefined);
|
|
176
210
|
}
|
|
177
|
-
/**
|
|
211
|
+
/** @deprecated Use `getPrivateKey(KeyId.ML_KEM_768)`. */
|
|
178
212
|
get mlKemSecretKey() {
|
|
179
|
-
return this.
|
|
213
|
+
return this._store.peekSecretKey(KeyId.ML_KEM_768);
|
|
180
214
|
}
|
|
181
|
-
/**
|
|
215
|
+
/** @deprecated Use `hasKey(KeyId.ML_KEM_768)`. */
|
|
182
216
|
get hasMlKem() {
|
|
183
|
-
return this.
|
|
217
|
+
return this._store.has(KeyId.ML_KEM_768);
|
|
184
218
|
}
|
|
185
|
-
/**
|
|
186
|
-
get
|
|
187
|
-
return this.
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
237
|
+
return this._store.has(KeyId.BTC)
|
|
238
|
+
? this._store.getPublicKey(KeyId.BTC)
|
|
239
|
+
: undefined;
|
|
196
240
|
}
|
|
197
|
-
/** @experimental
|
|
241
|
+
/** @experimental @deprecated Use `hasKey(KeyId.BTC)`. */
|
|
198
242
|
get hasBitcoin() {
|
|
199
|
-
return this.
|
|
243
|
+
return this._store.has(KeyId.BTC);
|
|
200
244
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
261
|
-
const
|
|
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
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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(
|
|
402
|
-
mlKemSecretKeyBase64: arrayToBase64(
|
|
403
|
-
edSecretKeyBase64: arrayToBase64(
|
|
404
|
-
mlDsaSecretKeyBase64: arrayToBase64(
|
|
405
|
-
btcSecretKeyBase64: this.
|
|
406
|
-
? arrayToBase64(
|
|
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
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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.
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
814
|
-
? arrayToBase64(this.
|
|
802
|
+
edPublicKeyBase64: this.edPublicKey
|
|
803
|
+
? arrayToBase64(this.edPublicKey)
|
|
815
804
|
: undefined,
|
|
816
|
-
mlDsaPublicKeyBase64: this.
|
|
817
|
-
? arrayToBase64(this.
|
|
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.
|
|
829
|
-
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.
|
|
833
|
-
mlKemSecretKey: this.
|
|
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.
|
|
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(
|
|
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
|
|
877
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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
|
-
|
|
911
|
-
|
|
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:
|
|
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
|
-
*
|
|
945
|
-
*
|
|
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
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
|
1137
|
-
|
|
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
|
-
// ──
|
|
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.
|
|
1061
|
+
return this._store.peekSecretKey(KeyId.BTC) !== undefined;
|
|
1164
1062
|
}
|
|
1165
1063
|
/**
|
|
1166
|
-
* @experimental Raw Bitcoin keypair material
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
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
|
|
1210
|
-
*
|
|
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.
|
|
1122
|
+
return this._store.peekSecretKey(KeyId.ED25519) !== undefined;
|
|
1214
1123
|
}
|
|
1215
1124
|
_getOrDeriveSolanaMaterial() {
|
|
1216
|
-
|
|
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(
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
}
|