@majikah/majik-key 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +566 -175
  2. package/dist/core/backup/index.d.ts +4 -4
  3. package/dist/core/backup/index.js +3 -3
  4. package/dist/core/backup/majik-key-backup.d.ts +3 -3
  5. package/dist/core/backup/majik-key-backup.js +5 -5
  6. package/dist/core/backup/types.d.ts +1 -1
  7. package/dist/core/backup/utils.js +1 -1
  8. package/dist/core/backup/validator.d.ts +1 -1
  9. package/dist/core/backup/validator.js +1 -1
  10. package/dist/core/crypto/constants.d.ts +35 -11
  11. package/dist/core/crypto/constants.js +33 -11
  12. package/dist/core/crypto/crypto-provider.js +8 -8
  13. package/dist/core/crypto/encryption-engine.d.ts +7 -16
  14. package/dist/core/crypto/encryption-engine.js +27 -62
  15. package/dist/core/database/system/identity.d.ts +2 -2
  16. package/dist/core/database/system/identity.js +2 -2
  17. package/dist/core/keys/hkdf-recipe.d.ts +4 -0
  18. package/dist/core/keys/hkdf-recipe.js +26 -0
  19. package/dist/core/keys/key-id.d.ts +50 -0
  20. package/dist/core/keys/key-id.js +64 -0
  21. package/dist/core/keys/key-impls.d.ts +12 -0
  22. package/dist/core/keys/key-impls.js +163 -0
  23. package/dist/core/keys/key-store.d.ts +72 -0
  24. package/dist/core/keys/key-store.js +264 -0
  25. package/dist/core/keys/keypair-handle.d.ts +36 -0
  26. package/dist/core/keys/keypair-handle.js +43 -0
  27. package/dist/core/keys/registry.d.ts +16 -0
  28. package/dist/core/keys/registry.js +144 -0
  29. package/dist/core/keys/types.d.ts +47 -0
  30. package/dist/core/keys/types.js +1 -0
  31. package/dist/core/types.d.ts +12 -1
  32. package/dist/core/utils.d.ts +1 -1
  33. package/dist/core/utils.js +1 -1
  34. package/dist/core/validator.d.ts +1 -1
  35. package/dist/core/validator.js +1 -1
  36. package/dist/core/web3/bitcoin/bitcoin.d.ts +1 -1
  37. package/dist/core/web3/bitcoin/bitcoin.js +4 -4
  38. package/dist/core/web3/bitcoin/types.d.ts +1 -1
  39. package/dist/core/web3/ethereum/constants.d.ts +1 -0
  40. package/dist/core/web3/ethereum/constants.js +4 -0
  41. package/dist/core/web3/ethereum/ethereum.d.ts +21 -0
  42. package/dist/core/web3/ethereum/ethereum.js +91 -0
  43. package/dist/core/web3/ethereum/types.d.ts +32 -0
  44. package/dist/core/web3/ethereum/types.js +1 -0
  45. package/dist/core/web3/index.d.ts +10 -5
  46. package/dist/core/web3/index.js +7 -2
  47. package/dist/core/web3/solana/solana.d.ts +2 -2
  48. package/dist/core/web3/solana/solana.js +6 -6
  49. package/dist/core/web3/solana/types.d.ts +1 -1
  50. package/dist/core/web3/types.d.ts +4 -2
  51. package/dist/index.d.ts +13 -6
  52. package/dist/index.js +11 -5
  53. package/dist/majik-key.d.ts +179 -281
  54. package/dist/majik-key.js +622 -726
  55. package/package.json +20 -5
@@ -0,0 +1,163 @@
1
+ /**
2
+ * key-impls.ts — Phase 2b: derivation implementations for registry keys.
3
+ *
4
+ * Every function here takes the 64-byte BIP-39 seed and returns a keypair.
5
+ * The "legacy-v1" recipes are FROZEN and deliberately inlined (domain strings,
6
+ * paths) instead of imported from constants files, so a refactor of a
7
+ * constants module can never silently change a derivation. They are pinned by
8
+ * vectors/legacy-v1.vectors.json (test/key-impls.test.ts + test-vectors.test.ts).
9
+ *
10
+ * New algorithms (phase 4) register here with the "hkdf-sha512-v1" recipe.
11
+ * Derived views (web3:sol) are NOT stored keys and are not listed here.
12
+ */
13
+ import * as ed25519 from "@stablelib/ed25519/ed25519.js";
14
+ import ed2curve from "ed2curve";
15
+ import { hash } from "@stablelib/sha256/sha256.js";
16
+ import { ml_kem512, ml_kem768, ml_kem1024, } from "@noble/post-quantum/ml-kem.js";
17
+ import { ml_dsa44, ml_dsa65, ml_dsa87 } from "@noble/post-quantum/ml-dsa.js";
18
+ import { slh_dsa_sha2_128s, slh_dsa_sha2_128f, slh_dsa_sha2_192s, slh_dsa_sha2_192f, slh_dsa_sha2_256s, slh_dsa_sha2_256f, slh_dsa_shake_128s, slh_dsa_shake_128f, slh_dsa_shake_192s, slh_dsa_shake_192f, slh_dsa_shake_256s, slh_dsa_shake_256f, } from "@noble/post-quantum/slh-dsa.js";
19
+ import { falcon512, falcon1024 } from "@noble/post-quantum/falcon.js";
20
+ import { deriveSeedHkdf } from "./hkdf-recipe.js";
21
+ import { HDKey } from "@scure/bip32/index.js";
22
+ import { MajikKeyError } from "../error.js";
23
+ import { KeyId } from "./key-id.js";
24
+ // ── frozen legacy-v1 recipe constants (do not "tidy" these) ──
25
+ const LEGACY_DSA_DOMAIN = "MajikSignatureSeedDSA";
26
+ const LEGACY_BTC_PATH = "m/84'/1971'/0'/0/0";
27
+ // Standard Ethereum path (SLIP-44 coin 60): MetaMask / Ledger / Trezor compatible.
28
+ const ETH_STANDARD_PATH = "m/44'/60'/0'/0/0";
29
+ function assertSeed(seed64) {
30
+ if (seed64.length !== 64)
31
+ throw new MajikKeyError(`Expected the 64-byte BIP-39 seed, got ${seed64.length} bytes`);
32
+ }
33
+ /** Ed25519 from seed[0..32]; X25519 is converted from this same keypair. */
34
+ function edFromSeed(seed64) {
35
+ return ed25519.generateKeyPairFromSeed(seed64.slice(0, 32));
36
+ }
37
+ const x25519Impl = {
38
+ id: KeyId.X25519,
39
+ derive(seed64) {
40
+ assertSeed(seed64);
41
+ const ed = edFromSeed(seed64);
42
+ const pk = ed2curve.convertPublicKey(ed.publicKey);
43
+ const sk = ed2curve.convertSecretKey(ed.secretKey);
44
+ if (!pk || !sk)
45
+ throw new MajikKeyError("Failed to convert derived Ed25519 keys to Curve25519");
46
+ return { publicKey: new Uint8Array(pk), secretKey: new Uint8Array(sk) };
47
+ },
48
+ };
49
+ const ed25519Impl = {
50
+ id: KeyId.ED25519,
51
+ derive(seed64) {
52
+ assertSeed(seed64);
53
+ const ed = edFromSeed(seed64);
54
+ return { publicKey: ed.publicKey, secretKey: ed.secretKey }; // 32 / 64 bytes
55
+ },
56
+ };
57
+ const mlKem768Impl = {
58
+ id: KeyId.ML_KEM_768,
59
+ derive(seed64) {
60
+ assertSeed(seed64);
61
+ return ml_kem768.keygen(seed64); // full 64-byte seed (legacy-v1)
62
+ },
63
+ };
64
+ const mlDsa87Impl = {
65
+ id: KeyId.ML_DSA_87,
66
+ derive(seed64) {
67
+ assertSeed(seed64);
68
+ const domain = new TextEncoder().encode(LEGACY_DSA_DOMAIN);
69
+ const input = new Uint8Array(seed64.length + domain.length);
70
+ input.set(seed64, 0);
71
+ input.set(domain, seed64.length);
72
+ try {
73
+ return ml_dsa87.keygen(hash(input)); // sha256(seed64 || domain) → 32-byte seed
74
+ }
75
+ finally {
76
+ input.fill(0);
77
+ }
78
+ },
79
+ };
80
+ const btcImpl = {
81
+ id: KeyId.BTC,
82
+ derive(seed64) {
83
+ assertSeed(seed64);
84
+ const child = HDKey.fromMasterSeed(seed64).derive(LEGACY_BTC_PATH);
85
+ if (!child.privateKey || !child.publicKey)
86
+ throw new MajikKeyError("Failed to derive Bitcoin keypair from seed");
87
+ return {
88
+ publicKey: child.publicKey.slice(),
89
+ secretKey: child.privateKey.slice(),
90
+ };
91
+ },
92
+ };
93
+ const ethImpl = {
94
+ id: KeyId.ETH,
95
+ derive(seed64) {
96
+ assertSeed(seed64);
97
+ const child = HDKey.fromMasterSeed(seed64).derive(ETH_STANDARD_PATH);
98
+ if (!child.privateKey || !child.publicKey)
99
+ throw new MajikKeyError("Failed to derive Ethereum keypair from seed");
100
+ return {
101
+ publicKey: child.publicKey.slice(),
102
+ secretKey: child.privateKey.slice(),
103
+ }; // 33 / 32 bytes
104
+ },
105
+ };
106
+ function hkdfImpl(id, algo, seedLength) {
107
+ return {
108
+ id,
109
+ derive(seed64) {
110
+ assertSeed(seed64);
111
+ if (algo.lengths.seed !== seedLength)
112
+ throw new MajikKeyError(`${id}: library seed length is ${algo.lengths.seed}, recipe expects ${seedLength}. Refusing to derive.`);
113
+ const seed = deriveSeedHkdf(seed64, id, seedLength);
114
+ try {
115
+ return algo.keygen(seed);
116
+ }
117
+ finally {
118
+ seed.fill(0);
119
+ }
120
+ },
121
+ };
122
+ }
123
+ const HKDF_IMPLS = [
124
+ hkdfImpl(KeyId.ML_KEM_512, ml_kem512, 64),
125
+ hkdfImpl(KeyId.ML_KEM_1024, ml_kem1024, 64),
126
+ hkdfImpl(KeyId.ML_DSA_44, ml_dsa44, 32),
127
+ hkdfImpl(KeyId.ML_DSA_65, ml_dsa65, 32),
128
+ hkdfImpl(KeyId.SLH_DSA_SHA2_128S, slh_dsa_sha2_128s, 48),
129
+ hkdfImpl(KeyId.SLH_DSA_SHA2_128F, slh_dsa_sha2_128f, 48),
130
+ hkdfImpl(KeyId.SLH_DSA_SHA2_192S, slh_dsa_sha2_192s, 72),
131
+ hkdfImpl(KeyId.SLH_DSA_SHA2_192F, slh_dsa_sha2_192f, 72),
132
+ hkdfImpl(KeyId.SLH_DSA_SHA2_256S, slh_dsa_sha2_256s, 96),
133
+ hkdfImpl(KeyId.SLH_DSA_SHA2_256F, slh_dsa_sha2_256f, 96),
134
+ hkdfImpl(KeyId.SLH_DSA_SHAKE_128S, slh_dsa_shake_128s, 48),
135
+ hkdfImpl(KeyId.SLH_DSA_SHAKE_128F, slh_dsa_shake_128f, 48),
136
+ hkdfImpl(KeyId.SLH_DSA_SHAKE_192S, slh_dsa_shake_192s, 72),
137
+ hkdfImpl(KeyId.SLH_DSA_SHAKE_192F, slh_dsa_shake_192f, 72),
138
+ hkdfImpl(KeyId.SLH_DSA_SHAKE_256S, slh_dsa_shake_256s, 96),
139
+ hkdfImpl(KeyId.SLH_DSA_SHAKE_256F, slh_dsa_shake_256f, 96),
140
+ // Falcon Round 3 (NOT FIPS 206) — experimental; ids are pq:falcon-*, not pq:fn-dsa-*
141
+ hkdfImpl(KeyId.FALCON_512, falcon512, 48),
142
+ hkdfImpl(KeyId.FALCON_1024, falcon1024, 48),
143
+ ];
144
+ export const KEY_IMPLS = Object.freeze({
145
+ [KeyId.X25519]: x25519Impl,
146
+ [KeyId.ED25519]: ed25519Impl,
147
+ [KeyId.ML_KEM_768]: mlKem768Impl,
148
+ [KeyId.ML_DSA_87]: mlDsa87Impl,
149
+ [KeyId.BTC]: btcImpl,
150
+ [KeyId.ETH]: ethImpl,
151
+ ...Object.fromEntries(HKDF_IMPLS.map((i) => [i.id, i])),
152
+ });
153
+ /** Derive the requested STORED keys from one BIP-39 seed. Caller zeroizes the seed. */
154
+ export function deriveKeys(seed64, ids) {
155
+ const out = new Map();
156
+ for (const id of ids) {
157
+ const impl = KEY_IMPLS[id];
158
+ if (!impl)
159
+ throw new MajikKeyError(`No derivation implementation registered for "${id}"`);
160
+ out.set(id, impl.derive(seed64));
161
+ }
162
+ return out;
163
+ }
@@ -0,0 +1,72 @@
1
+ import { KeyId } from "./key-id.js";
2
+ import type { DerivedKeypair } from "./key-impls.js";
3
+ import type { KeyDerivation, KeyEntryJSON } from "./types.js";
4
+ export interface KeySlot {
5
+ id: KeyId;
6
+ publicKey: Uint8Array;
7
+ /** IV(12) || AES-256-GCM ciphertext. Absent for public-only entries. */
8
+ encryptedSecretKey?: Uint8Array;
9
+ /** Raw secret. Present ONLY while the store is unlocked. */
10
+ secretKey?: Uint8Array;
11
+ derivation: KeyDerivation;
12
+ createdAt?: string;
13
+ }
14
+ /** Supplies the AES key for a slot (X25519 of legacy accounts may need PBKDF2, the rest Argon2id). */
15
+ export type KeyResolver = (slot: KeySlot) => Uint8Array;
16
+ /** The flat, pre-registry JSON fields (a subset of MajikKeyJSON). */
17
+ export interface LegacyKeyJSON {
18
+ publicKey: string;
19
+ encryptedPrivateKey?: string;
20
+ mlKemPublicKey?: string;
21
+ encryptedMlKemSecretKey?: string;
22
+ edPublicKey?: string;
23
+ encryptedEdSecretKey?: string;
24
+ mlDsaPublicKey?: string;
25
+ encryptedMlDsaSecretKey?: string;
26
+ btcPublicKey?: string;
27
+ encryptedBtcSecretKey?: string;
28
+ }
29
+ export declare class KeyStore {
30
+ private readonly slots;
31
+ /** Entries whose id this library version doesn't know. Preserved verbatim. */
32
+ private readonly opaque;
33
+ private _unlocked;
34
+ static seal(aesKey: Uint8Array, plaintext: Uint8Array): Uint8Array;
35
+ static open(aesKey: Uint8Array, blob: Uint8Array, label: string): Uint8Array;
36
+ /** Fresh derivation (create / importFromMnemonicBackup): seals every secret, returns UNLOCKED. */
37
+ static fromDerived(derived: ReadonlyMap<KeyId, DerivedKeypair>, aesKey: Uint8Array): KeyStore;
38
+ /** From the new `keys` JSON field. Unknown ids are preserved opaquely. */
39
+ static fromEntries(entries: readonly KeyEntryJSON[]): KeyStore;
40
+ /** Tier 1 migration: wrap the flat pre-registry fields. No secrets, no KDF needed. */
41
+ static fromLegacyJSON(j: LegacyKeyJSON): KeyStore;
42
+ get isUnlocked(): boolean;
43
+ has(id: string): boolean;
44
+ hasAll(ids: readonly string[]): boolean;
45
+ missing(ids: readonly KeyId[]): KeyId[];
46
+ /** Stored ids in canonical registry order. */
47
+ ids(): KeyId[];
48
+ get hasOpaqueSecrets(): boolean;
49
+ slot(id: KeyId): KeySlot | undefined;
50
+ getPublicKey(id: string): Uint8Array;
51
+ getSecretKey(id: string): Uint8Array;
52
+ /** Non-throwing: the raw secret if the store is unlocked and the slot has one. */
53
+ peekSecretKey(id: string): Uint8Array | undefined;
54
+ /**
55
+ * Install raw secrets onto existing slots and mark the store unlocked
56
+ * (fromDangerousJSON). Every id must already have a slot.
57
+ */
58
+ attachSecrets(secrets: ReadonlyMap<string, Uint8Array>): void;
59
+ /** Raw secrets of every slot (only while unlocked). Used by toDangerousJSON. */
60
+ exportSecrets(): Map<KeyId, Uint8Array>;
61
+ /** Decrypt every secret into temporaries; commit only if ALL succeed. */
62
+ unlock(keyFor: KeyResolver): void;
63
+ lock(): void;
64
+ /** Returns freshly sealed blobs under `newKey`. Mutates nothing. */
65
+ prepareReseal(oldKeyFor: KeyResolver, newKey: Uint8Array): Map<KeyId, Uint8Array>;
66
+ commitReseal(blobs: ReadonlyMap<KeyId, Uint8Array>): void;
67
+ /** Add keys after the fact (addKeys()). Caller has already sealed `secretKey`. */
68
+ add(slot: KeySlot): void;
69
+ toEntries(): KeyEntryJSON[];
70
+ /** Flat pre-registry fields — for `toJSON({ legacy: true })` so older readers keep working. */
71
+ toLegacyJSON(): LegacyKeyJSON;
72
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * key-store.ts — Phase 2c core: the single in-memory + at-rest container for
3
+ * every key on a MajikKey account. MajikKey (phase 2c-wire) delegates to this
4
+ * instead of holding one private field per algorithm.
5
+ *
6
+ * Responsibilities
7
+ * - hold one slot per KeyId: public key, encrypted secret (IV||AES-GCM),
8
+ * and — only while unlocked — the raw secret
9
+ * - atomic unlock / lock / re-encrypt (no half-states)
10
+ * - (de)serialize to the new `keys` entries AND to the legacy flat JSON fields
11
+ * (Tier 1 migration + optional legacy export)
12
+ * - round-trip entries from NEWER library versions untouched (opaque
13
+ * pass-through) so a downgrade-then-save never drops keys
14
+ *
15
+ * It never derives a KDF key itself: callers pass resolver functions, so the
16
+ * "one Argon2id run per operation" guarantee (phase 2a) stays in MajikKey.
17
+ */
18
+ import { MajikKeyError } from "../error.js";
19
+ import { aesGcmDecrypt, aesGcmEncrypt, generateRandomBytes, IV_LENGTH, } from "../crypto/crypto-provider.js";
20
+ import { arrayToBase64, base64ToUint8Array } from "../utils.js";
21
+ import { KeyId } from "./key-id.js";
22
+ import { KEY_ALGORITHMS, getAlgorithm, knownKeyIds } from "./registry.js";
23
+ const LEGACY_FIELDS = [
24
+ [KeyId.X25519, "publicKey", "encryptedPrivateKey"],
25
+ [KeyId.ML_KEM_768, "mlKemPublicKey", "encryptedMlKemSecretKey"],
26
+ [KeyId.ED25519, "edPublicKey", "encryptedEdSecretKey"],
27
+ [KeyId.ML_DSA_87, "mlDsaPublicKey", "encryptedMlDsaSecretKey"],
28
+ [KeyId.BTC, "btcPublicKey", "encryptedBtcSecretKey"],
29
+ ];
30
+ const zero = (u) => u?.fill(0);
31
+ export class KeyStore {
32
+ slots = new Map();
33
+ /** Entries whose id this library version doesn't know. Preserved verbatim. */
34
+ opaque = [];
35
+ _unlocked = false;
36
+ // ── crypto primitives (same on-disk format as every blob since v1) ────────
37
+ static seal(aesKey, plaintext) {
38
+ const iv = generateRandomBytes(IV_LENGTH);
39
+ const ct = aesGcmEncrypt(aesKey, iv, plaintext);
40
+ const out = new Uint8Array(iv.length + ct.length);
41
+ out.set(iv, 0);
42
+ out.set(ct, iv.length);
43
+ return out;
44
+ }
45
+ static open(aesKey, blob, label) {
46
+ const plain = aesGcmDecrypt(aesKey, blob.slice(0, IV_LENGTH), blob.slice(IV_LENGTH));
47
+ if (!plain)
48
+ throw new MajikKeyError(`Failed to decrypt ${label} — incorrect passphrase or corrupted data`);
49
+ return plain;
50
+ }
51
+ // ── construction ──────────────────────────────────────────────────────────
52
+ /** Fresh derivation (create / importFromMnemonicBackup): seals every secret, returns UNLOCKED. */
53
+ static fromDerived(derived, aesKey) {
54
+ const store = new KeyStore();
55
+ for (const [id, kp] of derived) {
56
+ store.slots.set(id, {
57
+ id,
58
+ publicKey: kp.publicKey,
59
+ secretKey: kp.secretKey,
60
+ encryptedSecretKey: KeyStore.seal(aesKey, kp.secretKey),
61
+ derivation: KEY_ALGORITHMS[id].derivation,
62
+ createdAt: new Date().toISOString(),
63
+ });
64
+ }
65
+ store._unlocked = true;
66
+ return store;
67
+ }
68
+ /** From the new `keys` JSON field. Unknown ids are preserved opaquely. */
69
+ static fromEntries(entries) {
70
+ const store = new KeyStore();
71
+ const seen = new Set();
72
+ for (const e of entries) {
73
+ if (!e || typeof e.id !== "string" || typeof e.publicKey !== "string")
74
+ throw new MajikKeyError("Invalid key entry in `keys`");
75
+ if (seen.has(e.id))
76
+ throw new MajikKeyError(`Duplicate key entry "${e.id}"`);
77
+ seen.add(e.id);
78
+ if (!getAlgorithm(e.id)) {
79
+ store.opaque.push(e); // from a newer version: keep, don't interpret
80
+ continue;
81
+ }
82
+ store.slots.set(e.id, {
83
+ id: e.id,
84
+ publicKey: base64ToUint8Array(e.publicKey),
85
+ encryptedSecretKey: e.encryptedSecretKey
86
+ ? base64ToUint8Array(e.encryptedSecretKey)
87
+ : undefined,
88
+ derivation: e.derivation,
89
+ createdAt: e.createdAt,
90
+ });
91
+ }
92
+ return store;
93
+ }
94
+ /** Tier 1 migration: wrap the flat pre-registry fields. No secrets, no KDF needed. */
95
+ static fromLegacyJSON(j) {
96
+ const store = new KeyStore();
97
+ for (const [id, pubField, encField] of LEGACY_FIELDS) {
98
+ const pub = j[pubField];
99
+ if (!pub)
100
+ continue;
101
+ const enc = j[encField];
102
+ store.slots.set(id, {
103
+ id,
104
+ publicKey: base64ToUint8Array(pub),
105
+ encryptedSecretKey: enc ? base64ToUint8Array(enc) : undefined,
106
+ derivation: KEY_ALGORITHMS[id].derivation,
107
+ });
108
+ }
109
+ if (!store.slots.has(KeyId.X25519))
110
+ throw new MajikKeyError("Legacy key JSON is missing the X25519 public key");
111
+ return store;
112
+ }
113
+ // ── queries ───────────────────────────────────────────────────────────────
114
+ get isUnlocked() {
115
+ return this._unlocked;
116
+ }
117
+ has(id) {
118
+ return this.slots.has(id);
119
+ }
120
+ hasAll(ids) {
121
+ return ids.every((i) => this.has(i));
122
+ }
123
+ missing(ids) {
124
+ return ids.filter((i) => !this.slots.has(i));
125
+ }
126
+ /** Stored ids in canonical registry order. */
127
+ ids() {
128
+ return knownKeyIds().filter((id) => this.slots.has(id));
129
+ }
130
+ get hasOpaqueSecrets() {
131
+ return this.opaque.some((e) => !!e.encryptedSecretKey);
132
+ }
133
+ slot(id) {
134
+ return this.slots.get(id);
135
+ }
136
+ getPublicKey(id) {
137
+ const s = this.slots.get(id);
138
+ if (!s)
139
+ throw new MajikKeyError(`No "${id}" key on this account`);
140
+ return s.publicKey;
141
+ }
142
+ getSecretKey(id) {
143
+ const s = this.slots.get(id);
144
+ if (!s)
145
+ throw new MajikKeyError(`No "${id}" key on this account`);
146
+ if (!this._unlocked || !s.secretKey)
147
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
148
+ return s.secretKey;
149
+ }
150
+ /** Non-throwing: the raw secret if the store is unlocked and the slot has one. */
151
+ peekSecretKey(id) {
152
+ return this._unlocked ? this.slots.get(id)?.secretKey : undefined;
153
+ }
154
+ /**
155
+ * Install raw secrets onto existing slots and mark the store unlocked
156
+ * (fromDangerousJSON). Every id must already have a slot.
157
+ */
158
+ attachSecrets(secrets) {
159
+ for (const [id, secret] of secrets) {
160
+ const s = this.slots.get(id);
161
+ if (!s)
162
+ throw new MajikKeyError(`Secret supplied for unknown key "${id}"`);
163
+ s.secretKey = secret;
164
+ }
165
+ this._unlocked = true;
166
+ }
167
+ /** Raw secrets of every slot (only while unlocked). Used by toDangerousJSON. */
168
+ exportSecrets() {
169
+ if (!this._unlocked)
170
+ throw new MajikKeyError("MajikKey is locked. Call unlock() first.");
171
+ const out = new Map();
172
+ for (const s of this.slots.values())
173
+ if (s.secretKey)
174
+ out.set(s.id, s.secretKey);
175
+ return out;
176
+ }
177
+ // ── lock / unlock (atomic) ────────────────────────────────────────────────
178
+ /** Decrypt every secret into temporaries; commit only if ALL succeed. */
179
+ unlock(keyFor) {
180
+ const staged = new Map();
181
+ try {
182
+ for (const slot of this.slots.values()) {
183
+ if (!slot.encryptedSecretKey)
184
+ continue;
185
+ staged.set(slot.id, KeyStore.open(keyFor(slot), slot.encryptedSecretKey, `${slot.id} secret key`));
186
+ }
187
+ }
188
+ catch (e) {
189
+ for (const p of staged.values())
190
+ zero(p);
191
+ throw e;
192
+ }
193
+ for (const [id, secret] of staged)
194
+ this.slots.get(id).secretKey = secret;
195
+ this._unlocked = true;
196
+ }
197
+ lock() {
198
+ for (const s of this.slots.values()) {
199
+ zero(s.secretKey);
200
+ s.secretKey = undefined;
201
+ }
202
+ this._unlocked = false;
203
+ }
204
+ // ── passphrase change / KDF migration (decrypt all → seal all → commit) ───
205
+ /** Returns freshly sealed blobs under `newKey`. Mutates nothing. */
206
+ prepareReseal(oldKeyFor, newKey) {
207
+ if (this.hasOpaqueSecrets)
208
+ throw new MajikKeyError("This account holds keys from a newer library version. Upgrade the library before changing the passphrase.");
209
+ const out = new Map();
210
+ for (const slot of this.slots.values()) {
211
+ if (!slot.encryptedSecretKey)
212
+ continue;
213
+ const plain = KeyStore.open(oldKeyFor(slot), slot.encryptedSecretKey, `${slot.id} secret key`);
214
+ try {
215
+ out.set(slot.id, KeyStore.seal(newKey, plain));
216
+ }
217
+ finally {
218
+ zero(plain);
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+ commitReseal(blobs) {
224
+ for (const [id, blob] of blobs)
225
+ this.slots.get(id).encryptedSecretKey = blob;
226
+ }
227
+ /** Add keys after the fact (addKeys()). Caller has already sealed `secretKey`. */
228
+ add(slot) {
229
+ if (this.slots.has(slot.id))
230
+ throw new MajikKeyError(`"${slot.id}" already exists on this account`);
231
+ this.slots.set(slot.id, slot);
232
+ }
233
+ // ── serialization ─────────────────────────────────────────────────────────
234
+ toEntries() {
235
+ const known = this.ids().map((id) => {
236
+ const s = this.slots.get(id);
237
+ return {
238
+ id,
239
+ publicKey: arrayToBase64(s.publicKey),
240
+ ...(s.encryptedSecretKey
241
+ ? { encryptedSecretKey: arrayToBase64(s.encryptedSecretKey) }
242
+ : {}),
243
+ derivation: s.derivation,
244
+ ...(s.createdAt ? { createdAt: s.createdAt } : {}),
245
+ };
246
+ });
247
+ return [...known, ...this.opaque];
248
+ }
249
+ /** Flat pre-registry fields — for `toJSON({ legacy: true })` so older readers keep working. */
250
+ toLegacyJSON() {
251
+ const out = {};
252
+ for (const [id, pubField, encField] of LEGACY_FIELDS) {
253
+ const s = this.slots.get(id);
254
+ if (!s)
255
+ continue;
256
+ out[pubField] = arrayToBase64(s.publicKey);
257
+ if (s.encryptedSecretKey)
258
+ out[encField] = arrayToBase64(s.encryptedSecretKey);
259
+ }
260
+ return out;
261
+ }
262
+ }
263
+ Object.freeze(KeyStore);
264
+ Object.freeze(KeyStore.prototype);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * keypair-handle.ts — the object returned by MajikKey.getKeypair(id).
3
+ *
4
+ * It reads LIVE through accessor closures instead of copying key bytes, so a
5
+ * handle obtained before lock() can never expose stale (zeroized) material or
6
+ * keep secrets alive: after lock(), `.private` throws and `.public` still works.
7
+ */
8
+ import type { KeyFamily, KeyId } from "./key-id.js";
9
+ import type { KeyPurpose, KeyStatus } from "./types.js";
10
+ export declare class MajikKeypair {
11
+ readonly id: KeyId;
12
+ private readonly readPublic;
13
+ private readonly readPrivate;
14
+ private readonly readUnlocked;
15
+ constructor(id: KeyId, readPublic: () => Uint8Array, readPrivate: () => Uint8Array, readUnlocked: () => boolean);
16
+ /** Namespaced algorithm id, e.g. "pq:ml-dsa-87". */
17
+ get algorithm(): KeyId;
18
+ get family(): KeyFamily;
19
+ get purpose(): KeyPurpose;
20
+ get status(): KeyStatus;
21
+ get isUnlocked(): boolean;
22
+ /** Public key bytes. Available while locked (except derived views like web3:sol). */
23
+ get public(): Uint8Array;
24
+ get publicBase64(): string;
25
+ /** Secret key bytes. Throws if the account is locked. ⚠️ Live key material. */
26
+ get private(): Uint8Array;
27
+ }
28
+ export interface KeyInfo {
29
+ id: KeyId;
30
+ family: KeyFamily;
31
+ purpose: KeyPurpose;
32
+ kind: "stored" | "derived";
33
+ status: KeyStatus;
34
+ /** Undefined for derived views while locked. */
35
+ publicKeyBase64?: string;
36
+ }
@@ -0,0 +1,43 @@
1
+ import { KEY_ALGORITHMS } from "./registry.js";
2
+ import { arrayToBase64 } from "../utils.js";
3
+ export class MajikKeypair {
4
+ id;
5
+ readPublic;
6
+ readPrivate;
7
+ readUnlocked;
8
+ constructor(id, readPublic, readPrivate, readUnlocked) {
9
+ this.id = id;
10
+ this.readPublic = readPublic;
11
+ this.readPrivate = readPrivate;
12
+ this.readUnlocked = readUnlocked;
13
+ }
14
+ /** Namespaced algorithm id, e.g. "pq:ml-dsa-87". */
15
+ get algorithm() {
16
+ return this.id;
17
+ }
18
+ get family() {
19
+ return KEY_ALGORITHMS[this.id].family;
20
+ }
21
+ get purpose() {
22
+ return KEY_ALGORITHMS[this.id].purpose;
23
+ }
24
+ get status() {
25
+ return KEY_ALGORITHMS[this.id].status;
26
+ }
27
+ get isUnlocked() {
28
+ return this.readUnlocked();
29
+ }
30
+ /** Public key bytes. Available while locked (except derived views like web3:sol). */
31
+ get public() {
32
+ return this.readPublic();
33
+ }
34
+ get publicBase64() {
35
+ return arrayToBase64(this.readPublic());
36
+ }
37
+ /** Secret key bytes. Throws if the account is locked. ⚠️ Live key material. */
38
+ get private() {
39
+ return this.readPrivate();
40
+ }
41
+ }
42
+ Object.freeze(MajikKeypair);
43
+ Object.freeze(MajikKeypair.prototype);
@@ -0,0 +1,16 @@
1
+ import { KeyFamily, KeyId } from "./key-id.js";
2
+ import type { KeyAlgorithmDefinition, KeyStatus } from "./types.js";
3
+ export declare const KEY_ALGORITHMS: Readonly<Record<KeyId, KeyAlgorithmDefinition>>;
4
+ export declare function getAlgorithm(id: string): KeyAlgorithmDefinition | undefined;
5
+ /** Everything the registry knows, in canonical order (includes reserved/unsupported). */
6
+ export declare function knownKeyIds(family?: KeyFamily): KeyId[];
7
+ /** Ids that can actually be enabled today. */
8
+ export declare function enableableKeyIds(): KeyId[];
9
+ /**
10
+ * Validate a caller's `keys` option and return the STORED key ids to create:
11
+ * CORE_KEYS ∪ requested, de-duplicated, canonical order. Derived views
12
+ * (e.g. web3:sol) are accepted as no-ops because they require a stored key
13
+ * that is already in the core set.
14
+ */
15
+ export declare function resolveRequestedKeys(requested?: readonly string[]): KeyId[];
16
+ export type { KeyStatus };