@omnituum/pqc-shared 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.
Files changed (40) hide show
  1. package/dist/crypto/index.cjs +91 -54
  2. package/dist/crypto/index.d.cts +43 -11
  3. package/dist/crypto/index.d.ts +43 -11
  4. package/dist/crypto/index.js +87 -55
  5. package/dist/{decrypt-eSHlbh1j.d.cts → decrypt-O1iqFIDL.d.cts} +80 -6
  6. package/dist/{decrypt-eSHlbh1j.d.ts → decrypt-O1iqFIDL.d.ts} +80 -6
  7. package/dist/fs/index.cjs +286 -147
  8. package/dist/fs/index.d.cts +28 -7
  9. package/dist/fs/index.d.ts +28 -7
  10. package/dist/fs/index.js +277 -147
  11. package/dist/index.cjs +368 -229
  12. package/dist/index.d.cts +4 -4
  13. package/dist/index.d.ts +4 -4
  14. package/dist/index.js +362 -230
  15. package/dist/{integrity-ByMp24VA.d.cts → integrity-BkBDrHA-.d.cts} +12 -4
  16. package/dist/{integrity-BPvNsC50.d.ts → integrity-DsFJv5c-.d.ts} +12 -4
  17. package/dist/{types-Dx_AFqow.d.cts → types-DmnVueAY.d.cts} +15 -5
  18. package/dist/{types-Dx_AFqow.d.ts → types-DmnVueAY.d.ts} +15 -5
  19. package/dist/utils/index.cjs +7 -10
  20. package/dist/utils/index.d.cts +2 -2
  21. package/dist/utils/index.d.ts +2 -2
  22. package/dist/utils/index.js +7 -10
  23. package/dist/vault/index.cjs +8 -11
  24. package/dist/vault/index.d.cts +2 -2
  25. package/dist/vault/index.d.ts +2 -2
  26. package/dist/vault/index.js +8 -11
  27. package/package.json +19 -25
  28. package/src/crypto/dilithium.ts +4 -4
  29. package/src/crypto/hybrid.ts +182 -80
  30. package/src/crypto/index.ts +8 -1
  31. package/src/crypto/kyber.ts +20 -0
  32. package/src/fs/decrypt.ts +145 -68
  33. package/src/fs/encrypt.ts +161 -112
  34. package/src/fs/format.ts +110 -15
  35. package/src/fs/index.ts +4 -0
  36. package/src/fs/types.ts +77 -6
  37. package/src/index.ts +9 -0
  38. package/src/utils/integrity.ts +16 -15
  39. package/src/vault/manager.ts +1 -1
  40. package/src/version.ts +29 -7
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * Post-quantum secure hybrid encryption combining:
5
5
  * - X25519 ECDH (classical, proven security)
6
- * - Kyber ML-KEM-768 (post-quantum, NIST Level 3)
6
+ * - ML-KEM-1024 (post-quantum, FIPS 203, NIST Level 5)
7
7
  *
8
- * Both key exchanges must succeed for decryption, providing
9
- * security against both classical and quantum attacks.
8
+ * v2 (current write format): the content key is wrapped once, under a KEK
9
+ * derived from BOTH shared secrets (HKDF(ss_mlkem || ss_x25519) with
10
+ * transcript binding) — both key exchanges must succeed to decrypt, so
11
+ * breaking either primitive alone is insufficient.
12
+ *
13
+ * v1 (read-only legacy): wrapped the key independently under each
14
+ * primitive; either secret alone sufficed. Kept only for decrypting
15
+ * existing envelopes — see the 2026-07-05 security audit.
10
16
  */
11
17
 
12
18
  import nacl from 'tweetnacl';
@@ -30,8 +36,8 @@ import {
30
36
  isKyberAvailable,
31
37
  } from './kyber';
32
38
  import {
33
- ENVELOPE_VERSION,
34
- ENVELOPE_SUITE,
39
+ ENVELOPE_VERSION_V2,
40
+ ENVELOPE_SUITE_V2,
35
41
  ENVELOPE_AEAD,
36
42
  assertEnvelopeVersion,
37
43
  } from '../version';
@@ -76,22 +82,45 @@ export interface HybridSecretKeys {
76
82
  }
77
83
 
78
84
  /**
79
- * HybridEnvelope -- OmniHybridV1 from the registry with
85
+ * HybridEnvelopeV1 -- OmniHybridV1 from the registry with
80
86
  * app-semantic meta fields (senderName, senderId).
81
87
  * The Omni registry type defines only the crypto-relevant surface.
82
88
  *
83
89
  * Intentionally a type alias (not interface extends) to discourage
84
90
  * further field additions here. New app-semantic fields belong in
85
91
  * product-level types, not in the shared crypto layer.
92
+ *
93
+ * READ-ONLY LEGACY: v1 wraps the content key independently under X25519
94
+ * and Kyber, so either secret alone unwraps it — min(X25519, ML-KEM)
95
+ * security. hybridEncrypt no longer produces this shape; hybridDecrypt
96
+ * still reads it.
86
97
  */
87
98
  import type { OmniHybridV1 } from '@omnituum/envelope-registry';
88
- export type HybridEnvelope = Omit<OmniHybridV1, 'meta'> & {
99
+ export type HybridEnvelopeV1 = Omit<OmniHybridV1, 'meta'> & {
89
100
  meta: OmniHybridV1['meta'] & {
90
101
  senderName?: string;
91
102
  senderId?: string;
92
103
  };
93
104
  };
94
105
 
106
+ /**
107
+ * HybridEnvelopeV2 -- OmniHybridV2 from the registry with app-semantic meta
108
+ * fields (senderName, senderId), same pattern as HybridEnvelopeV1. Single
109
+ * wrap of the content key under an AND-combined KEK:
110
+ * HKDF-SHA256(ss_mlkem || ss_x25519) with the envelope's own KEM values
111
+ * bound into the info string. Both primitives must be broken to unwrap.
112
+ */
113
+ import type { OmniHybridV2 } from '@omnituum/envelope-registry';
114
+ export type HybridEnvelopeV2 = Omit<OmniHybridV2, 'meta'> & {
115
+ meta: OmniHybridV2['meta'] & {
116
+ senderName?: string;
117
+ senderId?: string;
118
+ };
119
+ };
120
+
121
+ /** Any hybrid envelope this module can decrypt. */
122
+ export type HybridEnvelope = HybridEnvelopeV1 | HybridEnvelopeV2;
123
+
95
124
  // ═══════════════════════════════════════════════════════════════════════════
96
125
  // HELPERS
97
126
  // ═══════════════════════════════════════════════════════════════════════════
@@ -119,7 +148,7 @@ export async function generateHybridIdentity(name: string): Promise<HybridIdenti
119
148
  // Generate Kyber keypair
120
149
  const kyber = await generateKyberKeypair();
121
150
  if (!kyber) {
122
- console.error('Kyber key generation failed - library not available');
151
+ // Backend unavailable; caller handles the null return.
123
152
  return null;
124
153
  }
125
154
 
@@ -184,7 +213,34 @@ export function getSecretKeys(identity: HybridIdentity): HybridSecretKeys {
184
213
  // ═══════════════════════════════════════════════════════════════════════════
185
214
 
186
215
  /**
187
- * Encrypt a message using hybrid X25519 + Kyber encryption.
216
+ * Derive the v2 AND-combined KEK. Requires BOTH shared secrets; the
217
+ * envelope's own KEM values are bound into the info string so a spliced
218
+ * epk/kemCt from another envelope derives a different KEK and the wrap
219
+ * fails authentication (X-Wing-style transcript binding).
220
+ */
221
+ function combinedKekV2(
222
+ kyberShared: Uint8Array,
223
+ x25519Shared: Uint8Array,
224
+ x25519EpkHex: string,
225
+ kyberKemCtB64: string
226
+ ): Uint8Array {
227
+ const ikm = new Uint8Array(kyberShared.length + x25519Shared.length);
228
+ ikm.set(kyberShared, 0);
229
+ ikm.set(x25519Shared, kyberShared.length);
230
+ try {
231
+ return hkdfFlex(ikm, 'omnituum/hybrid-v2', `wrap-ck|${x25519EpkHex}|${kyberKemCtB64}`);
232
+ } finally {
233
+ ikm.fill(0);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Encrypt a message using hybrid X25519 + ML-KEM-1024 encryption (v2).
239
+ *
240
+ * The content key is wrapped exactly once, under a KEK derived from both
241
+ * shared secrets together — breaking either primitive alone is insufficient
242
+ * to unwrap. (v1 wrapped the key independently under each primitive, which
243
+ * reduced security to min(X25519, ML-KEM); v1 is now read-only legacy.)
188
244
  *
189
245
  * @param plaintext - Message to encrypt (string or bytes)
190
246
  * @param recipientPublicKeys - Recipient's public keys
@@ -194,83 +250,104 @@ export async function hybridEncrypt(
194
250
  plaintext: string | Uint8Array,
195
251
  recipientPublicKeys: HybridPublicKeys,
196
252
  sender?: { name?: string; id?: string }
197
- ): Promise<HybridEnvelope> {
253
+ ): Promise<HybridEnvelopeV2> {
198
254
  const pt = typeof plaintext === 'string' ? textEncoder.encode(plaintext) : plaintext;
199
255
 
200
256
  // 1. Generate random content key (32 bytes)
201
257
  const CK = rand32();
202
258
 
203
- // 2. Encrypt content with content key
204
- const contentNonce = rand24();
205
- const ciphertext = nacl.secretbox(pt, contentNonce, CK);
206
-
207
- // 3. Wrap content key with X25519 ECDH
208
- const x25519EphKp = nacl.box.keyPair();
209
- const recipientX25519Pk = fromHex(recipientPublicKeys.x25519PubHex);
210
-
211
- const x25519Shared = nacl.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
212
- const x25519Kek = hkdfFlex(x25519Shared, 'omnituum/x25519', 'wrap-ck');
213
- const x25519WrapNonce = rand24();
214
- const x25519Wrapped = nacl.secretbox(CK, x25519WrapNonce, x25519Kek);
215
-
216
- // 4. Wrap content key with Kyber KEM
217
- const kyberResult = await kyberEncapsulate(recipientPublicKeys.kyberPubB64);
218
- const kyberKek = hkdfFlex(kyberResult.sharedSecret, 'omnituum/kyber', 'wrap-ck');
219
- const kyberWrapNonce = rand24();
220
- const kyberWrapped = nacl.secretbox(CK, kyberWrapNonce, kyberKek);
221
-
222
- return {
223
- v: ENVELOPE_VERSION,
224
- suite: ENVELOPE_SUITE,
225
- aead: ENVELOPE_AEAD,
226
- x25519Epk: toHex(x25519EphKp.publicKey),
227
- x25519Wrap: {
228
- nonce: b64(x25519WrapNonce),
229
- wrapped: b64(x25519Wrapped),
230
- },
231
- kyberKemCt: b64(kyberResult.ciphertext),
232
- kyberWrap: {
233
- nonce: b64(kyberWrapNonce),
234
- wrapped: b64(kyberWrapped),
235
- },
236
- contentNonce: b64(contentNonce),
237
- ciphertext: b64(ciphertext),
238
- meta: {
239
- createdAt: new Date().toISOString(),
240
- senderName: sender?.name,
241
- senderId: sender?.id,
242
- },
243
- };
259
+ try {
260
+ // 2. Encrypt content with content key
261
+ const contentNonce = rand24();
262
+ const ciphertext = nacl.secretbox(pt, contentNonce, CK);
263
+
264
+ // 3. X25519 ECDH shared secret (ephemeral)
265
+ const x25519EphKp = nacl.box.keyPair();
266
+ const recipientX25519Pk = fromHex(recipientPublicKeys.x25519PubHex);
267
+ const x25519Shared = nacl.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
268
+ const x25519EpkHex = toHex(x25519EphKp.publicKey);
269
+
270
+ // 4. ML-KEM-1024 shared secret
271
+ const kyberResult = await kyberEncapsulate(recipientPublicKeys.kyberPubB64);
272
+ const kyberKemCtB64 = b64(kyberResult.ciphertext);
273
+
274
+ // 5. Single wrap under the AND-combined KEK
275
+ const kek = combinedKekV2(kyberResult.sharedSecret, x25519Shared, x25519EpkHex, kyberKemCtB64);
276
+ const ckWrapNonce = rand24();
277
+ const ckWrapped = nacl.secretbox(CK, ckWrapNonce, kek);
278
+ kek.fill(0);
279
+ x25519Shared.fill(0);
280
+ kyberResult.sharedSecret.fill(0);
281
+ x25519EphKp.secretKey.fill(0);
282
+
283
+ return {
284
+ v: ENVELOPE_VERSION_V2,
285
+ suite: ENVELOPE_SUITE_V2,
286
+ aead: ENVELOPE_AEAD,
287
+ x25519Epk: x25519EpkHex,
288
+ kyberKemCt: kyberKemCtB64,
289
+ ckWrap: {
290
+ nonce: b64(ckWrapNonce),
291
+ wrapped: b64(ckWrapped),
292
+ },
293
+ contentNonce: b64(contentNonce),
294
+ ciphertext: b64(ciphertext),
295
+ meta: {
296
+ createdAt: new Date().toISOString(),
297
+ senderName: sender?.name,
298
+ senderId: sender?.id,
299
+ },
300
+ };
301
+ } finally {
302
+ CK.fill(0);
303
+ }
244
304
  }
245
305
 
246
306
  // ═══════════════════════════════════════════════════════════════════════════
247
307
  // DECRYPTION
248
308
  // ═══════════════════════════════════════════════════════════════════════════
249
309
 
310
+ /** Unwrap the content key from a v2 envelope — requires BOTH secrets. */
311
+ async function unwrapCkV2(
312
+ envelope: HybridEnvelopeV2,
313
+ secretKeys: HybridSecretKeys
314
+ ): Promise<Uint8Array> {
315
+ // ML-KEM decapsulation (post-quantum half). Failure here is fatal — there
316
+ // is deliberately no classical fallback path in v2.
317
+ const kyberShared = await kyberDecapsulate(envelope.kyberKemCt, secretKeys.kyberSecB64);
318
+
319
+ // X25519 ECDH (classical half)
320
+ const ephPk = fromHex(envelope.x25519Epk);
321
+ const sk = fromHex(secretKeys.x25519SecHex);
322
+ const x25519Shared = nacl.scalarMult(sk, ephPk);
323
+
324
+ const kek = combinedKekV2(kyberShared, x25519Shared, envelope.x25519Epk, envelope.kyberKemCt);
325
+ kyberShared.fill(0);
326
+ x25519Shared.fill(0);
327
+
328
+ const CK = nacl.secretbox.open(ub64(envelope.ckWrap.wrapped), ub64(envelope.ckWrap.nonce), kek);
329
+ kek.fill(0);
330
+ if (!CK) {
331
+ throw new Error('Could not unwrap content key — combined-KEK authentication failed');
332
+ }
333
+ return CK;
334
+ }
335
+
250
336
  /**
251
- * Decrypt a hybrid envelope.
337
+ * Unwrap the content key from a v1 (or pqc-demo) envelope.
252
338
  *
253
- * Tries Kyber first (post-quantum), falls back to X25519 (classical).
254
- * Both must be valid for the envelope to be considered secure.
255
- *
256
- * @param envelope - Encrypted envelope
257
- * @param secretKeys - Recipient's secret keys
258
- * @returns Decrypted plaintext as bytes
339
+ * LEGACY READ PATH: v1 wrapped the key independently under each primitive,
340
+ * so either secret alone suffices this is exactly the defect v2 fixes.
341
+ * Kept only so envelopes written before v2 remain readable; re-encrypt to
342
+ * v2 where the post-quantum guarantee matters.
259
343
  */
260
- export async function hybridDecrypt(
261
- envelope: HybridEnvelope,
262
- secretKeys: HybridSecretKeys
344
+ async function unwrapCkV1(
345
+ envelope: HybridEnvelopeV1,
346
+ secretKeys: HybridSecretKeys,
347
+ saltPrefix: string
263
348
  ): Promise<Uint8Array> {
264
- // Validate envelope version (throws VersionMismatchError if unsupported)
265
- const version = envelope.v as string;
266
- assertEnvelopeVersion(version);
267
-
268
349
  let CK: Uint8Array | null = null;
269
350
 
270
- // Determine HKDF salt based on envelope version
271
- const saltPrefix = version === 'pqc-demo.hybrid.v1' ? 'pqc-demo' : 'omnituum';
272
-
273
- // Try Kyber decapsulation first (post-quantum)
274
351
  try {
275
352
  const kyberShared = await kyberDecapsulate(envelope.kyberKemCt, secretKeys.kyberSecB64);
276
353
  const kyberKek = hkdfFlex(kyberShared, `${saltPrefix}/kyber`, 'wrap-ck');
@@ -279,14 +356,10 @@ export async function hybridDecrypt(
279
356
  ub64(envelope.kyberWrap.nonce),
280
357
  kyberKek
281
358
  );
282
- if (CK) {
283
- console.log('[Hybrid] Decrypted using Kyber (post-quantum)');
284
- }
285
- } catch (e) {
286
- console.warn('[Hybrid] Kyber decapsulation failed:', e);
359
+ } catch {
360
+ // Fall through to the X25519 wrap.
287
361
  }
288
362
 
289
- // Try X25519 if Kyber failed
290
363
  if (!CK) {
291
364
  try {
292
365
  const ephPk = fromHex(envelope.x25519Epk);
@@ -298,17 +371,45 @@ export async function hybridDecrypt(
298
371
  ub64(envelope.x25519Wrap.nonce),
299
372
  x25519Kek
300
373
  );
301
- if (CK) {
302
- console.log('[Hybrid] Decrypted using X25519 (classical)');
303
- }
304
- } catch (e) {
305
- console.warn('[Hybrid] X25519 decryption failed:', e);
374
+ } catch {
375
+ // Handled below.
306
376
  }
307
377
  }
308
378
 
309
379
  if (!CK) {
310
380
  throw new Error('Could not unwrap content key with either algorithm');
311
381
  }
382
+ return CK;
383
+ }
384
+
385
+ /**
386
+ * Decrypt a hybrid envelope (v2, v1, or legacy pqc-demo).
387
+ *
388
+ * v2: the content key is unwrapped from a single AND-combined KEK — both
389
+ * ML-KEM and X25519 secrets are required, and any failure is fatal.
390
+ * v1/legacy: read-only compatibility path (either secret suffices — the
391
+ * defect v2 exists to fix).
392
+ *
393
+ * @param envelope - Encrypted envelope
394
+ * @param secretKeys - Recipient's secret keys
395
+ * @returns Decrypted plaintext as bytes
396
+ */
397
+ export async function hybridDecrypt(
398
+ envelope: HybridEnvelope,
399
+ secretKeys: HybridSecretKeys
400
+ ): Promise<Uint8Array> {
401
+ // Validate envelope version (throws VersionMismatchError if unsupported)
402
+ const version = envelope.v as string;
403
+ assertEnvelopeVersion(version);
404
+
405
+ const CK =
406
+ version === ENVELOPE_VERSION_V2
407
+ ? await unwrapCkV2(envelope as HybridEnvelopeV2, secretKeys)
408
+ : await unwrapCkV1(
409
+ envelope as HybridEnvelopeV1,
410
+ secretKeys,
411
+ version === 'pqc-demo.hybrid.v1' ? 'pqc-demo' : 'omnituum'
412
+ );
312
413
 
313
414
  // Decrypt content
314
415
  const pt = nacl.secretbox.open(
@@ -316,6 +417,7 @@ export async function hybridDecrypt(
316
417
  ub64(envelope.contentNonce),
317
418
  CK
318
419
  );
420
+ CK.fill(0);
319
421
 
320
422
  if (!pt) {
321
423
  throw new Error('Content authentication failed');
@@ -71,7 +71,7 @@ export {
71
71
  } from './x25519';
72
72
 
73
73
  // ═══════════════════════════════════════════════════════════════════════════
74
- // KYBER ML-KEM-768 (Post-Quantum KEM)
74
+ // KYBER ML-KEM-1024 (FIPS 203 — Post-Quantum KEM)
75
75
  // ═══════════════════════════════════════════════════════════════════════════
76
76
 
77
77
  export type {
@@ -89,6 +89,11 @@ export {
89
89
  kyberWrapKey,
90
90
  kyberUnwrapKey,
91
91
  KYBER_SUITE,
92
+ KYBER_PUBLIC_KEY_SIZE,
93
+ KYBER_SECRET_KEY_SIZE,
94
+ KYBER_CIPHERTEXT_SIZE,
95
+ KYBER_SHARED_SECRET_SIZE,
96
+ KYBER_SEED_SIZE,
92
97
  } from './kyber';
93
98
  export type { KyberSuite } from './kyber';
94
99
 
@@ -125,6 +130,8 @@ export type {
125
130
  HybridPublicKeys,
126
131
  HybridSecretKeys,
127
132
  HybridEnvelope,
133
+ HybridEnvelopeV1,
134
+ HybridEnvelopeV2,
128
135
  } from './hybrid';
129
136
 
130
137
  export {
@@ -45,6 +45,26 @@ export interface KyberEncapsulation {
45
45
  export const KYBER_SUITE = 'ML-KEM-1024-FIPS203' as const;
46
46
  export type KyberSuite = typeof KYBER_SUITE;
47
47
 
48
+ // ─── Wire-format size constants (ML-KEM-1024, FIPS 203) ──────────────────────
49
+ // Exported so consumers can validate inputs and allocate buffers without
50
+ // restating magic literals. Tracked under PQC-08; mirrors the precedent
51
+ // established by DILITHIUM_PUBLIC_KEY_SIZE et al. in `./dilithium`.
52
+
53
+ /** ML-KEM-1024 public key size (bytes). */
54
+ export const KYBER_PUBLIC_KEY_SIZE = 1568;
55
+
56
+ /** ML-KEM-1024 secret key size (bytes). */
57
+ export const KYBER_SECRET_KEY_SIZE = 3168;
58
+
59
+ /** ML-KEM-1024 ciphertext size (bytes). */
60
+ export const KYBER_CIPHERTEXT_SIZE = 1568;
61
+
62
+ /** ML-KEM-1024 shared-secret size (bytes). */
63
+ export const KYBER_SHARED_SECRET_SIZE = 32;
64
+
65
+ /** Seed size accepted by `generateKyberKeypairFromSeed` (bytes). */
66
+ export const KYBER_SEED_SIZE = 64;
67
+
48
68
  // ═══════════════════════════════════════════════════════════════════════════
49
69
  // AVAILABILITY
50
70
  // ═══════════════════════════════════════════════════════════════════════════