@f0rest8/metamorphic-crypto 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @f0rest8/metamorphic-crypto
2
2
 
3
- Zero-knowledge end-to-end encryption with post-quantum hybrid KEM (ML-KEM-768/1024 + X25519).
3
+ Zero-knowledge end-to-end encryption with post-quantum hybrid KEM (ML-KEM-512/768/1024 + X25519).
4
4
 
5
5
  Built for [Metamorphic](https://metamorphic.app) and [Mosslet](https://mosslet.com) — privacy-first apps by [Moss Piglet Corporation](https://mosspiglet.dev) where all user data is encrypted client-side and the server only stores opaque ciphertext.
6
6
 
@@ -103,7 +103,17 @@ import { generateHybridKeyPair1024, sealForUserWithLevel } from "@f0rest8/metamo
103
103
 
104
104
  const pq5 = generateHybridKeyPair1024(); // { publicKey, secretKey } (ML-KEM-1024)
105
105
  const sealed = sealForUserWithLevel(plaintextBase64, x25519.publicKey, pq5.publicKey, "cat5");
106
- // unsealFromUser auto-detects Cat-3 vs Cat-5, no level param needed
106
+ // unsealFromUser auto-detects Cat-1 vs Cat-3 vs Cat-5, no level param needed
107
+ ```
108
+
109
+ ### Cat-1 (ML-KEM-512, opt-in)
110
+
111
+ ```js
112
+ import { generateHybridKeyPair512, sealForUserWithLevel } from "@f0rest8/metamorphic-crypto";
113
+
114
+ const pq1 = generateHybridKeyPair512(); // { publicKey, secretKey } (ML-KEM-512)
115
+ const sealed = sealForUserWithLevel(plaintextBase64, x25519.publicKey, pq1.publicKey, "cat1");
116
+ // unsealFromUser auto-detects the level from the version tag, no level param needed
107
117
  ```
108
118
 
109
119
  ### Private key management
@@ -176,14 +186,47 @@ Stable wire format (reproduce exactly to match native/Elixir):
176
186
  > right construction instead: this package's Argon2id `deriveSessionKey` for
177
187
  > password-based derivation, or a dedicated KDF/MAC.
178
188
 
189
+ ### Hybrid PQ signatures (ML-DSA + Ed25519)
190
+
191
+ Composite digital signatures: every message is signed by **both** ML-DSA
192
+ (FIPS 204) and Ed25519 (RFC 8032), and `verify` returns `true` only if **both**
193
+ component signatures are valid (strict AND). Keys and signatures are base64; the
194
+ message is **base64** and `context` is a UTF-8 string.
195
+
196
+ ```js
197
+ import { generateSigningKeyPair, sign, verify, deriveSigningPublicKey } from "@f0rest8/metamorphic-crypto";
198
+
199
+ const kp = generateSigningKeyPair("cat3"); // { publicKey, secretKey } — Cat-3 default
200
+ const msg = btoa("transparency log entry");
201
+ const sig = sign(msg, "metamorphic/sign/v1", kp.secretKey);
202
+ const ok = verify(msg, "metamorphic/sign/v1", sig, kp.publicKey); // true
203
+
204
+ // Re-derive the public key from a backed-up secret key:
205
+ const pk = deriveSigningPublicKey(kp.secretKey); // === kp.publicKey
206
+ ```
207
+
208
+ Levels are `"cat2"` (ML-DSA-44), `"cat3"` (ML-DSA-65, default), and `"cat5"`
209
+ (ML-DSA-87); `verify` auto-detects the level from the signature. ML-DSA uses the
210
+ hedged/randomized FIPS 204 mode, so signature **bytes are non-reproducible**,
211
+ but key derivation and the domain-separation framing
212
+ (`u64_be(len(context)) || context || message`) are deterministic and identical
213
+ across native Rust, WASM, and the Elixir NIF.
214
+
215
+ > **Audit posture.** `ed25519-dalek` (2.x) is mature and widely deployed.
216
+ > `ml-dsa` (0.1.x, RustCrypto FIPS 204) is new and **not yet independently
217
+ > audited** — it is included as defense-in-depth on top of Ed25519, so the
218
+ > composite remains at least as strong as Ed25519 even if a flaw is found in the
219
+ > young post-quantum implementation. Pinned and tracked for the FIPS-mode roadmap.
220
+
179
221
  ## Security levels
180
222
 
181
223
  | Level | ML-KEM | NIST Category | Equivalent | Default |
182
224
  |-------|--------|---------------|------------|---------|
225
+ | Cat-1 | 512 | 1 | ~AES-128 | No |
183
226
  | Cat-3 | 768 | 3 | ~AES-192 | Yes |
184
227
  | Cat-5 | 1024 | 5 | ~AES-256 | No |
185
228
 
186
- Decryption always auto-detects the level from the ciphertext version tag.
229
+ Decryption always auto-detects the level from the ciphertext version tag. NIST (FIPS 203) standardizes ML-KEM only at categories 1/3/5 — there is no category-2/4 set. The classical half is X25519 (~Cat-1 classical) at every tier; at Cat-3/Cat-5 the post-quantum half dominates and X25519 is the classical floor (standard hybrid-KEM practice).
187
230
 
188
231
  ## Security properties
189
232
 
@@ -37,6 +37,11 @@ export function decryptSecretboxToString(ciphertext_b64: string, key_b64: string
37
37
  */
38
38
  export function deriveSessionKey(password: string, salt_b64: string): string;
39
39
 
40
+ /**
41
+ * Re-derive the base64 public key from a base64 hybrid secret key.
42
+ */
43
+ export function deriveSigningPublicKey(secret_key_b64: string): string;
44
+
40
45
  /**
41
46
  * Encrypt a base64 private key with a session key. Returns base64 ciphertext.
42
47
  */
@@ -67,6 +72,11 @@ export function generateHybridKeyPair(): any;
67
72
  */
68
73
  export function generateHybridKeyPair1024(): any;
69
74
 
75
+ /**
76
+ * Generate a ML-KEM-512 + X25519 keypair (Cat-1). Returns JSON: `{ publicKey, secretKey }`.
77
+ */
78
+ export function generateHybridKeyPair512(): any;
79
+
70
80
  /**
71
81
  * Generate a random 32-byte symmetric key (base64).
72
82
  */
@@ -87,6 +97,14 @@ export function generateRecoveryKey(): any;
87
97
  */
88
98
  export function generateSalt(): string;
89
99
 
100
+ /**
101
+ * Generate a hybrid signing keypair. Returns JSON: `{ publicKey, secretKey }`.
102
+ *
103
+ * `level` is `"cat2"` (ML-DSA-44), `"cat3"` (ML-DSA-65, default), or `"cat5"`
104
+ * (ML-DSA-87). Empty/null defaults to Cat-3.
105
+ */
106
+ export function generateSigningKeyPair(level: string): any;
107
+
90
108
  /**
91
109
  * Check if a base64 ciphertext is hybrid (v2/v3) format.
92
110
  */
@@ -110,7 +128,8 @@ export function sealForUser(plaintext_b64: string, public_key_b64: string, pq_pu
110
128
  /**
111
129
  * Seal plaintext bytes (base64) to a user's key(s) at a specific security level.
112
130
  *
113
- * `level` must be `"cat3"` (ML-KEM-768, default) or `"cat5"` (ML-KEM-1024).
131
+ * `level` must be `"cat1"` (ML-KEM-512), `"cat3"` (ML-KEM-768, default), or
132
+ * `"cat5"` (ML-KEM-1024).
114
133
  * If `pq_public_key_b64` is absent or empty, falls back to legacy X25519.
115
134
  */
116
135
  export function sealForUserWithLevel(plaintext_b64: string, public_key_b64: string, pq_public_key_b64: string | null | undefined, level: string): string;
@@ -148,12 +167,25 @@ export function sha3_512WithContext(context: string, data_b64: string): string;
148
167
  */
149
168
  export function sha512(data_b64: string): string;
150
169
 
170
+ /**
171
+ * Sign base64 `message` under `context` with a base64 hybrid `secret_key`.
172
+ * Returns the composite signature as base64.
173
+ */
174
+ export function sign(message_b64: string, context: string, secret_key_b64: string): string;
175
+
151
176
  /**
152
177
  * Unseal ciphertext using the user's keys. Auto-detects format.
153
178
  * Returns base64-encoded plaintext.
154
179
  */
155
180
  export function unsealFromUser(ciphertext_b64: string, public_key_b64: string, private_key_b64: string, pq_secret_key_b64?: string | null): string;
156
181
 
182
+ /**
183
+ * Verify a base64 composite `signature` over base64 `message`/`context`
184
+ * against a base64 `public_key`. Returns `true` only if **both** the Ed25519
185
+ * and ML-DSA components verify (strict AND).
186
+ */
187
+ export function verify(message_b64: string, context: string, signature_b64: string, public_key_b64: string): boolean;
188
+
157
189
  export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
158
190
 
159
191
  export interface InitOutput {
@@ -166,6 +198,7 @@ export interface InitOutput {
166
198
  readonly sealForUser: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
167
199
  readonly unsealFromUser: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void;
168
200
  readonly sealForUserWithLevel: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void;
201
+ readonly generateHybridKeyPair512: () => number;
169
202
  readonly generateHybridKeyPair: () => number;
170
203
  readonly generateHybridKeyPair1024: () => number;
171
204
  readonly isHybridCiphertext: (a: number, b: number) => number;
@@ -182,10 +215,14 @@ export interface InitOutput {
182
215
  readonly sha3_256: (a: number, b: number, c: number) => void;
183
216
  readonly sha256: (a: number, b: number, c: number) => void;
184
217
  readonly sha512: (a: number, b: number, c: number) => void;
185
- readonly encryptSecretboxString: (a: number, b: number, c: number, d: number, e: number) => void;
186
- readonly encryptPrivateKeyForRecovery: (a: number, b: number, c: number, d: number, e: number) => void;
218
+ readonly generateSigningKeyPair: (a: number, b: number, c: number) => void;
219
+ readonly deriveSigningPublicKey: (a: number, b: number, c: number) => void;
220
+ readonly sign: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
221
+ readonly verify: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => void;
187
222
  readonly decryptPrivateKeyWithRecovery: (a: number, b: number, c: number, d: number, e: number) => void;
188
223
  readonly decryptSecretboxToString: (a: number, b: number, c: number, d: number, e: number) => void;
224
+ readonly encryptSecretboxString: (a: number, b: number, c: number, d: number, e: number) => void;
225
+ readonly encryptPrivateKeyForRecovery: (a: number, b: number, c: number, d: number, e: number) => void;
189
226
  readonly __wbindgen_export: (a: number, b: number) => number;
190
227
  readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
191
228
  readonly __wbindgen_export3: (a: number) => void;
@@ -249,6 +249,38 @@ export function deriveSessionKey(password, salt_b64) {
249
249
  }
250
250
  }
251
251
 
252
+ /**
253
+ * Re-derive the base64 public key from a base64 hybrid secret key.
254
+ * @param {string} secret_key_b64
255
+ * @returns {string}
256
+ */
257
+ export function deriveSigningPublicKey(secret_key_b64) {
258
+ let deferred3_0;
259
+ let deferred3_1;
260
+ try {
261
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
262
+ const ptr0 = passStringToWasm0(secret_key_b64, wasm.__wbindgen_export, wasm.__wbindgen_export2);
263
+ const len0 = WASM_VECTOR_LEN;
264
+ wasm.deriveSigningPublicKey(retptr, ptr0, len0);
265
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
266
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
267
+ var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
268
+ var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
269
+ var ptr2 = r0;
270
+ var len2 = r1;
271
+ if (r3) {
272
+ ptr2 = 0; len2 = 0;
273
+ throw takeObject(r2);
274
+ }
275
+ deferred3_0 = ptr2;
276
+ deferred3_1 = len2;
277
+ return getStringFromWasm0(ptr2, len2);
278
+ } finally {
279
+ wasm.__wbindgen_add_to_stack_pointer(16);
280
+ wasm.__wbindgen_export4(deferred3_0, deferred3_1, 1);
281
+ }
282
+ }
283
+
252
284
  /**
253
285
  * Encrypt a base64 private key with a session key. Returns base64 ciphertext.
254
286
  * @param {string} private_key_b64
@@ -407,6 +439,15 @@ export function generateHybridKeyPair1024() {
407
439
  return takeObject(ret);
408
440
  }
409
441
 
442
+ /**
443
+ * Generate a ML-KEM-512 + X25519 keypair (Cat-1). Returns JSON: `{ publicKey, secretKey }`.
444
+ * @returns {any}
445
+ */
446
+ export function generateHybridKeyPair512() {
447
+ const ret = wasm.generateHybridKeyPair512();
448
+ return takeObject(ret);
449
+ }
450
+
410
451
  /**
411
452
  * Generate a random 32-byte symmetric key (base64).
412
453
  * @returns {string}
@@ -478,6 +519,32 @@ export function generateSalt() {
478
519
  }
479
520
  }
480
521
 
522
+ /**
523
+ * Generate a hybrid signing keypair. Returns JSON: `{ publicKey, secretKey }`.
524
+ *
525
+ * `level` is `"cat2"` (ML-DSA-44), `"cat3"` (ML-DSA-65, default), or `"cat5"`
526
+ * (ML-DSA-87). Empty/null defaults to Cat-3.
527
+ * @param {string} level
528
+ * @returns {any}
529
+ */
530
+ export function generateSigningKeyPair(level) {
531
+ try {
532
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
533
+ const ptr0 = passStringToWasm0(level, wasm.__wbindgen_export, wasm.__wbindgen_export2);
534
+ const len0 = WASM_VECTOR_LEN;
535
+ wasm.generateSigningKeyPair(retptr, ptr0, len0);
536
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
537
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
538
+ var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
539
+ if (r2) {
540
+ throw takeObject(r1);
541
+ }
542
+ return takeObject(r0);
543
+ } finally {
544
+ wasm.__wbindgen_add_to_stack_pointer(16);
545
+ }
546
+ }
547
+
481
548
  /**
482
549
  * Check if a base64 ciphertext is hybrid (v2/v3) format.
483
550
  * @param {string} ciphertext_b64
@@ -595,7 +662,8 @@ export function sealForUser(plaintext_b64, public_key_b64, pq_public_key_b64) {
595
662
  /**
596
663
  * Seal plaintext bytes (base64) to a user's key(s) at a specific security level.
597
664
  *
598
- * `level` must be `"cat3"` (ML-KEM-768, default) or `"cat5"` (ML-KEM-1024).
665
+ * `level` must be `"cat1"` (ML-KEM-512), `"cat3"` (ML-KEM-768, default), or
666
+ * `"cat5"` (ML-KEM-1024).
599
667
  * If `pq_public_key_b64` is absent or empty, falls back to legacy X25519.
600
668
  * @param {string} plaintext_b64
601
669
  * @param {string} public_key_b64
@@ -807,6 +875,45 @@ export function sha512(data_b64) {
807
875
  }
808
876
  }
809
877
 
878
+ /**
879
+ * Sign base64 `message` under `context` with a base64 hybrid `secret_key`.
880
+ * Returns the composite signature as base64.
881
+ * @param {string} message_b64
882
+ * @param {string} context
883
+ * @param {string} secret_key_b64
884
+ * @returns {string}
885
+ */
886
+ export function sign(message_b64, context, secret_key_b64) {
887
+ let deferred5_0;
888
+ let deferred5_1;
889
+ try {
890
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
891
+ const ptr0 = passStringToWasm0(message_b64, wasm.__wbindgen_export, wasm.__wbindgen_export2);
892
+ const len0 = WASM_VECTOR_LEN;
893
+ const ptr1 = passStringToWasm0(context, wasm.__wbindgen_export, wasm.__wbindgen_export2);
894
+ const len1 = WASM_VECTOR_LEN;
895
+ const ptr2 = passStringToWasm0(secret_key_b64, wasm.__wbindgen_export, wasm.__wbindgen_export2);
896
+ const len2 = WASM_VECTOR_LEN;
897
+ wasm.sign(retptr, ptr0, len0, ptr1, len1, ptr2, len2);
898
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
899
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
900
+ var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
901
+ var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
902
+ var ptr4 = r0;
903
+ var len4 = r1;
904
+ if (r3) {
905
+ ptr4 = 0; len4 = 0;
906
+ throw takeObject(r2);
907
+ }
908
+ deferred5_0 = ptr4;
909
+ deferred5_1 = len4;
910
+ return getStringFromWasm0(ptr4, len4);
911
+ } finally {
912
+ wasm.__wbindgen_add_to_stack_pointer(16);
913
+ wasm.__wbindgen_export4(deferred5_0, deferred5_1, 1);
914
+ }
915
+ }
916
+
810
917
  /**
811
918
  * Unseal ciphertext using the user's keys. Auto-detects format.
812
919
  * Returns base64-encoded plaintext.
@@ -848,6 +955,40 @@ export function unsealFromUser(ciphertext_b64, public_key_b64, private_key_b64,
848
955
  wasm.__wbindgen_export4(deferred6_0, deferred6_1, 1);
849
956
  }
850
957
  }
958
+
959
+ /**
960
+ * Verify a base64 composite `signature` over base64 `message`/`context`
961
+ * against a base64 `public_key`. Returns `true` only if **both** the Ed25519
962
+ * and ML-DSA components verify (strict AND).
963
+ * @param {string} message_b64
964
+ * @param {string} context
965
+ * @param {string} signature_b64
966
+ * @param {string} public_key_b64
967
+ * @returns {boolean}
968
+ */
969
+ export function verify(message_b64, context, signature_b64, public_key_b64) {
970
+ try {
971
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
972
+ const ptr0 = passStringToWasm0(message_b64, wasm.__wbindgen_export, wasm.__wbindgen_export2);
973
+ const len0 = WASM_VECTOR_LEN;
974
+ const ptr1 = passStringToWasm0(context, wasm.__wbindgen_export, wasm.__wbindgen_export2);
975
+ const len1 = WASM_VECTOR_LEN;
976
+ const ptr2 = passStringToWasm0(signature_b64, wasm.__wbindgen_export, wasm.__wbindgen_export2);
977
+ const len2 = WASM_VECTOR_LEN;
978
+ const ptr3 = passStringToWasm0(public_key_b64, wasm.__wbindgen_export, wasm.__wbindgen_export2);
979
+ const len3 = WASM_VECTOR_LEN;
980
+ wasm.verify(retptr, ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3);
981
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
982
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
983
+ var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
984
+ if (r2) {
985
+ throw takeObject(r1);
986
+ }
987
+ return r0 !== 0;
988
+ } finally {
989
+ wasm.__wbindgen_add_to_stack_pointer(16);
990
+ }
991
+ }
851
992
  function __wbg_get_imports() {
852
993
  const import0 = {
853
994
  __proto__: null,
Binary file
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@f0rest8/metamorphic-crypto",
3
3
  "type": "module",
4
- "description": "Zero-knowledge end-to-end encryption with post-quantum hybrid KEM (ML-KEM-768/1024 + X25519)",
5
- "version": "0.4.0",
4
+ "description": "Zero-knowledge end-to-end encryption with post-quantum hybrid KEM (ML-KEM-512/768/1024 + X25519)",
5
+ "version": "0.6.0",
6
6
  "license": "MIT OR Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",