@omnituum/pqc-shared 0.4.1 → 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 (39) hide show
  1. package/dist/crypto/index.cjs +81 -54
  2. package/dist/crypto/index.d.cts +33 -11
  3. package/dist/crypto/index.d.ts +33 -11
  4. package/dist/crypto/index.js +81 -54
  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 +358 -229
  12. package/dist/index.d.cts +4 -4
  13. package/dist/index.d.ts +4 -4
  14. package/dist/index.js +357 -230
  15. package/dist/{integrity-BenoFsmP.d.cts → integrity-BkBDrHA-.d.cts} +12 -4
  16. package/dist/{integrity-D9J98Bty.d.ts → integrity-DsFJv5c-.d.ts} +12 -4
  17. package/dist/{types-CRka8PC7.d.cts → types-DmnVueAY.d.cts} +14 -4
  18. package/dist/{types-CRka8PC7.d.ts → types-DmnVueAY.d.ts} +14 -4
  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 +7 -7
  28. package/src/crypto/dilithium.ts +4 -4
  29. package/src/crypto/hybrid.ts +182 -80
  30. package/src/crypto/index.ts +2 -0
  31. package/src/fs/decrypt.ts +145 -68
  32. package/src/fs/encrypt.ts +161 -112
  33. package/src/fs/format.ts +110 -15
  34. package/src/fs/index.ts +4 -0
  35. package/src/fs/types.ts +77 -6
  36. package/src/index.ts +4 -0
  37. package/src/utils/integrity.ts +16 -15
  38. package/src/vault/manager.ts +1 -1
  39. package/src/version.ts +29 -7
package/dist/fs/index.js CHANGED
@@ -1,17 +1,28 @@
1
1
  import { argon2id } from 'hash-wasm';
2
2
  import { sha256 as sha256$1 } from '@noble/hashes/sha2.js';
3
3
  import { hmac } from '@noble/hashes/hmac.js';
4
- import nacl2 from 'tweetnacl';
4
+ import nacl3 from 'tweetnacl';
5
5
  import { ml_kem1024 } from '@noble/post-quantum/ml-kem.js';
6
6
 
7
7
  // src/fs/types.ts
8
8
  var OQE_MAGIC = new Uint8Array([79, 81, 69, 70]);
9
- var OQE_FORMAT_VERSION = 1;
9
+ var OQE_FORMAT_VERSION_V1 = 1;
10
+ var OQE_FORMAT_VERSION_V2 = 2;
11
+ var OQE_FORMAT_VERSION = OQE_FORMAT_VERSION_V2;
12
+ var SUPPORTED_OQE_VERSIONS = [OQE_FORMAT_VERSION_V1, OQE_FORMAT_VERSION_V2];
10
13
  var ALGORITHM_SUITES = {
11
- /** Hybrid: X25519 ECDH + Kyber768 KEM + AES-256-GCM */
14
+ /**
15
+ * LEGACY (read-only): Hybrid X25519 + Kyber with independent per-primitive
16
+ * wraps (either secret unwraps). Only appears in v1 files. Never written.
17
+ */
12
18
  HYBRID_X25519_KYBER768_AES256GCM: 1,
13
19
  /** Password: Argon2id + AES-256-GCM */
14
- PASSWORD_ARGON2ID_AES256GCM: 2
20
+ PASSWORD_ARGON2ID_AES256GCM: 2,
21
+ /**
22
+ * Hybrid X25519 + ML-KEM-1024 with a single AND-combined KEK wrap
23
+ * (HKDF(ss_mlkem || ss_x25519), transcript-bound). Written by v2.
24
+ */
25
+ HYBRID_X25519_MLKEM1024_AES256GCM: 3
15
26
  };
16
27
  var DEFAULT_ARGON2ID_PARAMS = {
17
28
  memoryCost: 65536,
@@ -29,7 +40,9 @@ var MIN_ARGON2ID_PARAMS = {
29
40
  hashLength: 32,
30
41
  saltLength: 32
31
42
  };
32
- var OQE_HEADER_SIZE = 30;
43
+ var OQE_HEADER_SIZE_V1 = 30;
44
+ var OQE_HEADER_SIZE_V2 = 42;
45
+ var OQE_HEADER_SIZE = OQE_HEADER_SIZE_V1;
33
46
  var OQEError = class extends Error {
34
47
  constructor(code, message) {
35
48
  super(message);
@@ -115,6 +128,7 @@ function hkdfSha256(ikm, opts) {
115
128
  }
116
129
  return out;
117
130
  }
131
+ var b64 = toB64;
118
132
  var ub64 = fromB64;
119
133
  var u8 = (s) => typeof s === "string" ? textEncoder.encode(s) : s;
120
134
 
@@ -344,6 +358,9 @@ async function aesDecryptStreaming(encrypted, key, onProgress) {
344
358
  }
345
359
 
346
360
  // src/fs/format.ts
361
+ function oqeHeaderSize(version) {
362
+ return version === OQE_FORMAT_VERSION_V2 ? OQE_HEADER_SIZE_V2 : OQE_HEADER_SIZE_V1;
363
+ }
347
364
  function writeUint32BE(value) {
348
365
  const buffer = new ArrayBuffer(4);
349
366
  new DataView(buffer).setUint32(0, value, false);
@@ -361,7 +378,8 @@ function readUint16BE(data, offset) {
361
378
  return new DataView(data.buffer, data.byteOffset + offset).getUint16(0, false);
362
379
  }
363
380
  function writeOQEHeader(header) {
364
- const buffer = new Uint8Array(OQE_HEADER_SIZE);
381
+ const isV2 = header.version === OQE_FORMAT_VERSION_V2;
382
+ const buffer = new Uint8Array(oqeHeaderSize(header.version));
365
383
  let offset = 0;
366
384
  buffer.set(OQE_MAGIC, offset);
367
385
  offset += 4;
@@ -374,11 +392,18 @@ function writeOQEHeader(header) {
374
392
  buffer.set(writeUint32BE(header.keyMaterialLength), offset);
375
393
  offset += 4;
376
394
  buffer.set(header.iv, offset);
395
+ offset += 12;
396
+ if (isV2) {
397
+ if (!header.contentIv || header.contentIv.length !== 12) {
398
+ throw new OQEError("INVALID_HEADER", "v2 header requires a 12-byte contentIv");
399
+ }
400
+ buffer.set(header.contentIv, offset);
401
+ }
377
402
  return buffer;
378
403
  }
379
404
  function parseOQEHeader(data) {
380
- if (data.length < OQE_HEADER_SIZE) {
381
- throw new OQEError("INVALID_HEADER", `File too small: need ${OQE_HEADER_SIZE} bytes, got ${data.length}`);
405
+ if (data.length < OQE_HEADER_SIZE_V1) {
406
+ throw new OQEError("INVALID_HEADER", `File too small: need at least ${OQE_HEADER_SIZE_V1} bytes, got ${data.length}`);
382
407
  }
383
408
  let offset = 0;
384
409
  const magic = data.slice(0, 4);
@@ -387,14 +412,19 @@ function parseOQEHeader(data) {
387
412
  }
388
413
  offset += 4;
389
414
  const version = data[offset++];
390
- if (version !== OQE_FORMAT_VERSION) {
415
+ if (!SUPPORTED_OQE_VERSIONS.includes(version)) {
391
416
  throw new OQEError("UNSUPPORTED_VERSION", `Unsupported OQE version: ${version}`);
392
417
  }
393
418
  const algorithmSuiteRaw = data[offset++];
394
- if (algorithmSuiteRaw !== ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM && algorithmSuiteRaw !== ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM) {
419
+ if (algorithmSuiteRaw !== ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM && algorithmSuiteRaw !== ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM && algorithmSuiteRaw !== ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM) {
395
420
  throw new OQEError("UNSUPPORTED_ALGORITHM", `Unsupported algorithm suite: 0x${algorithmSuiteRaw.toString(16)}`);
396
421
  }
397
422
  const algorithmSuite = algorithmSuiteRaw;
423
+ const isV2 = version === OQE_FORMAT_VERSION_V2;
424
+ const headerSize = oqeHeaderSize(version);
425
+ if (data.length < headerSize) {
426
+ throw new OQEError("INVALID_HEADER", `Truncated v${version} header: need ${headerSize} bytes, got ${data.length}`);
427
+ }
398
428
  const flags = readUint32BE(data, offset);
399
429
  offset += 4;
400
430
  const metadataLength = readUint32BE(data, offset);
@@ -402,13 +432,16 @@ function parseOQEHeader(data) {
402
432
  const keyMaterialLength = readUint32BE(data, offset);
403
433
  offset += 4;
404
434
  const iv = data.slice(offset, offset + 12);
435
+ offset += 12;
436
+ const contentIv = isV2 ? data.slice(offset, offset + 12) : void 0;
405
437
  return {
406
438
  version,
407
439
  algorithmSuite,
408
440
  flags,
409
441
  metadataLength,
410
442
  keyMaterialLength,
411
- iv
443
+ iv,
444
+ contentIv
412
445
  };
413
446
  }
414
447
  function serializeHybridKeyMaterial(km) {
@@ -459,6 +492,38 @@ function parseHybridKeyMaterial(data) {
459
492
  kyberWrappedKey
460
493
  };
461
494
  }
495
+ function serializeHybridKeyMaterialV2(km) {
496
+ const parts = [];
497
+ parts.push(km.x25519EphemeralPk);
498
+ parts.push(writeUint16BE(km.kyberCiphertext.length));
499
+ parts.push(km.kyberCiphertext);
500
+ parts.push(km.ckWrapNonce);
501
+ parts.push(writeUint16BE(km.ckWrapped.length));
502
+ parts.push(km.ckWrapped);
503
+ const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
504
+ const result = new Uint8Array(totalLength);
505
+ let offset = 0;
506
+ for (const part of parts) {
507
+ result.set(part, offset);
508
+ offset += part.length;
509
+ }
510
+ return result;
511
+ }
512
+ function parseHybridKeyMaterialV2(data) {
513
+ let offset = 0;
514
+ const x25519EphemeralPk = data.slice(offset, offset + 32);
515
+ offset += 32;
516
+ const kyberCtLen = readUint16BE(data, offset);
517
+ offset += 2;
518
+ const kyberCiphertext = data.slice(offset, offset + kyberCtLen);
519
+ offset += kyberCtLen;
520
+ const ckWrapNonce = data.slice(offset, offset + 24);
521
+ offset += 24;
522
+ const ckWrappedLen = readUint16BE(data, offset);
523
+ offset += 2;
524
+ const ckWrapped = data.slice(offset, offset + ckWrappedLen);
525
+ return { x25519EphemeralPk, kyberCiphertext, ckWrapNonce, ckWrapped };
526
+ }
462
527
  function serializePasswordKeyMaterial(km) {
463
528
  const result = new Uint8Array(32 + 4 + 4 + 4);
464
529
  result.set(km.salt, 0);
@@ -500,7 +565,7 @@ function assembleOQEFile(components) {
500
565
  }
501
566
  function parseOQEFile(data) {
502
567
  const header = parseOQEHeader(data);
503
- let offset = OQE_HEADER_SIZE;
568
+ let offset = oqeHeaderSize(header.version);
504
569
  const keyMaterial = data.slice(offset, offset + header.keyMaterialLength);
505
570
  offset += header.keyMaterialLength;
506
571
  const encryptedMetadata = data.slice(offset, offset + header.metadataLength);
@@ -535,8 +600,11 @@ function isOQEFile(filenameOrData) {
535
600
  }
536
601
  var OQE_MIME_TYPE = "application/x-omnituum-encrypted";
537
602
  function getAlgorithmName(suiteId) {
603
+ if (suiteId === ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM) {
604
+ return "Hybrid (X25519 + ML-KEM-1024 + AES-256-GCM)";
605
+ }
538
606
  if (suiteId === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
539
- return "Hybrid (X25519 + Kyber768 + AES-256-GCM)";
607
+ return "Hybrid legacy (X25519 + Kyber + AES-256-GCM, either-key)";
540
608
  }
541
609
  if (suiteId === ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM) {
542
610
  return "Password (Argon2id + AES-256-GCM)";
@@ -561,61 +629,81 @@ async function kyberDecapsulate(kemCiphertextB64, secretKeyB64) {
561
629
  }
562
630
 
563
631
  // src/fs/encrypt.ts
564
- function hkdfFlex(ikm, salt, info) {
565
- return hkdfSha256(ikm, { salt: u8(salt), info: u8(info), length: 32 });
566
- }
632
+ var AES_GCM_TAG_LEN = AES_GCM_TAG_SIZE;
567
633
  function computeIdentityHash(publicKeyHex) {
568
634
  const hash = sha256(fromHex(publicKeyHex));
569
635
  return toHex(hash).slice(0, 16);
570
636
  }
637
+ function combinedFileKekV2(kyberShared, x25519Shared, x25519EpkHex, kyberKemCtB64) {
638
+ const ikm = new Uint8Array(kyberShared.length + x25519Shared.length);
639
+ ikm.set(kyberShared, 0);
640
+ ikm.set(x25519Shared, kyberShared.length);
641
+ try {
642
+ return hkdfSha256(ikm, {
643
+ salt: u8("omnituum/fs/hybrid-v2"),
644
+ info: u8(`wrap-content-key|${x25519EpkHex}|${kyberKemCtB64}`),
645
+ length: 32
646
+ });
647
+ } finally {
648
+ ikm.fill(0);
649
+ }
650
+ }
571
651
  async function encryptHybrid(plaintext, metadata, options) {
572
652
  if (!await isKyberAvailable()) {
573
653
  throw new OQEError("KYBER_UNAVAILABLE", "Kyber library not available in this environment");
574
654
  }
575
655
  const contentKey = rand32();
576
- const iv = rand12();
577
- const x25519EphKp = nacl2.box.keyPair();
578
- const recipientX25519Pk = fromHex(options.recipientPublicKeys.x25519PubHex);
579
- const x25519Shared = nacl2.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
580
- const x25519Kek = hkdfFlex(x25519Shared, "omnituum/fs/x25519", "wrap-content-key");
581
- const x25519Nonce = rand24();
582
- const x25519WrappedKey = nacl2.secretbox(contentKey, x25519Nonce, x25519Kek);
583
- const kyberResult = await kyberEncapsulate(options.recipientPublicKeys.kyberPubB64);
584
- const kyberKek = hkdfFlex(kyberResult.sharedSecret, "omnituum/fs/kyber", "wrap-content-key");
585
- const kyberNonce = rand24();
586
- const kyberWrappedKey = nacl2.secretbox(contentKey, kyberNonce, kyberKek);
587
- const keyMaterial = {
588
- x25519EphemeralPk: x25519EphKp.publicKey,
589
- x25519Nonce,
590
- x25519WrappedKey,
591
- kyberCiphertext: kyberResult.ciphertext,
592
- kyberNonce,
593
- kyberWrappedKey
594
- };
595
- const keyMaterialBytes = serializeHybridKeyMaterial(keyMaterial);
596
- const metadataBytes = serializeMetadata(metadata);
597
- const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, iv);
598
- const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, iv);
599
- const header = {
600
- version: OQE_FORMAT_VERSION,
601
- algorithmSuite: ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM,
602
- flags: 0,
603
- metadataLength: encryptedMetadata.length,
604
- keyMaterialLength: keyMaterialBytes.length,
605
- iv
606
- };
607
- const fileData = assembleOQEFile({
608
- header,
609
- keyMaterial: keyMaterialBytes,
610
- encryptedMetadata,
611
- encryptedContent
612
- });
613
- return {
614
- data: fileData,
615
- filename: addOQEExtension(metadata.filename),
616
- metadata,
617
- mode: "hybrid"
618
- };
656
+ try {
657
+ const metadataIv = rand12();
658
+ const contentIv = rand12();
659
+ const x25519EphKp = nacl3.box.keyPair();
660
+ const recipientX25519Pk = fromHex(options.recipientPublicKeys.x25519PubHex);
661
+ const x25519Shared = nacl3.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
662
+ const x25519EpkHex = toHex(x25519EphKp.publicKey);
663
+ const kyberResult = await kyberEncapsulate(options.recipientPublicKeys.kyberPubB64);
664
+ const kyberKemCtB64 = b64(kyberResult.ciphertext);
665
+ const kek = combinedFileKekV2(kyberResult.sharedSecret, x25519Shared, x25519EpkHex, kyberKemCtB64);
666
+ const ckWrapNonce = rand24();
667
+ const ckWrapped = nacl3.secretbox(contentKey, ckWrapNonce, kek);
668
+ kek.fill(0);
669
+ x25519Shared.fill(0);
670
+ kyberResult.sharedSecret.fill(0);
671
+ x25519EphKp.secretKey.fill(0);
672
+ const keyMaterial = {
673
+ x25519EphemeralPk: x25519EphKp.publicKey,
674
+ kyberCiphertext: kyberResult.ciphertext,
675
+ ckWrapNonce,
676
+ ckWrapped
677
+ };
678
+ const keyMaterialBytes = serializeHybridKeyMaterialV2(keyMaterial);
679
+ const metadataBytes = serializeMetadata(metadata);
680
+ const header = {
681
+ version: OQE_FORMAT_VERSION_V2,
682
+ algorithmSuite: ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM,
683
+ flags: 0,
684
+ metadataLength: metadataBytes.length + AES_GCM_TAG_LEN,
685
+ keyMaterialLength: keyMaterialBytes.length,
686
+ iv: metadataIv,
687
+ contentIv
688
+ };
689
+ const aad = writeOQEHeader(header);
690
+ const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, metadataIv, aad);
691
+ const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, contentIv, aad);
692
+ const fileData = assembleOQEFile({
693
+ header,
694
+ keyMaterial: keyMaterialBytes,
695
+ encryptedMetadata,
696
+ encryptedContent
697
+ });
698
+ return {
699
+ data: fileData,
700
+ filename: addOQEExtension(metadata.filename),
701
+ metadata,
702
+ mode: "hybrid"
703
+ };
704
+ } finally {
705
+ contentKey.fill(0);
706
+ }
619
707
  }
620
708
  async function encryptPassword(plaintext, metadata, options) {
621
709
  if (!await isArgon2Available()) {
@@ -627,37 +715,44 @@ async function encryptPassword(plaintext, metadata, options) {
627
715
  };
628
716
  const salt = generateArgon2Salt(params.saltLength);
629
717
  const contentKey = await deriveKeyFromPassword(options.password, salt, params);
630
- const iv = rand12();
631
- const keyMaterial = {
632
- salt,
633
- memoryCost: params.memoryCost,
634
- timeCost: params.timeCost,
635
- parallelism: params.parallelism
636
- };
637
- const keyMaterialBytes = serializePasswordKeyMaterial(keyMaterial);
638
- const metadataBytes = serializeMetadata(metadata);
639
- const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, iv);
640
- const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, iv);
641
- const header = {
642
- version: OQE_FORMAT_VERSION,
643
- algorithmSuite: ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM,
644
- flags: 0,
645
- metadataLength: encryptedMetadata.length,
646
- keyMaterialLength: keyMaterialBytes.length,
647
- iv
648
- };
649
- const fileData = assembleOQEFile({
650
- header,
651
- keyMaterial: keyMaterialBytes,
652
- encryptedMetadata,
653
- encryptedContent
654
- });
655
- return {
656
- data: fileData,
657
- filename: addOQEExtension(metadata.filename),
658
- metadata,
659
- mode: "password"
660
- };
718
+ try {
719
+ const metadataIv = rand12();
720
+ const contentIv = rand12();
721
+ const keyMaterial = {
722
+ salt,
723
+ memoryCost: params.memoryCost,
724
+ timeCost: params.timeCost,
725
+ parallelism: params.parallelism
726
+ };
727
+ const keyMaterialBytes = serializePasswordKeyMaterial(keyMaterial);
728
+ const metadataBytes = serializeMetadata(metadata);
729
+ const header = {
730
+ version: OQE_FORMAT_VERSION_V2,
731
+ algorithmSuite: ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM,
732
+ flags: 0,
733
+ metadataLength: metadataBytes.length + AES_GCM_TAG_LEN,
734
+ keyMaterialLength: keyMaterialBytes.length,
735
+ iv: metadataIv,
736
+ contentIv
737
+ };
738
+ const aad = writeOQEHeader(header);
739
+ const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, metadataIv, aad);
740
+ const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, contentIv, aad);
741
+ const fileData = assembleOQEFile({
742
+ header,
743
+ keyMaterial: keyMaterialBytes,
744
+ encryptedMetadata,
745
+ encryptedContent
746
+ });
747
+ return {
748
+ data: fileData,
749
+ filename: addOQEExtension(metadata.filename),
750
+ metadata,
751
+ mode: "password"
752
+ };
753
+ } finally {
754
+ contentKey.fill(0);
755
+ }
661
756
  }
662
757
  async function encryptFile(input, options) {
663
758
  const plaintext = await toUint8Array(input.data);
@@ -698,17 +793,56 @@ async function encryptFileWithPassword(input, password) {
698
793
  password
699
794
  });
700
795
  }
701
- function hkdfFlex2(ikm, salt, info) {
796
+ function hkdfFlex(ikm, salt, info) {
702
797
  return hkdfSha256(ikm, { salt: u8(salt), info: u8(info), length: 32 });
703
798
  }
799
+ function headerAad(header) {
800
+ return writeOQEHeader(header);
801
+ }
704
802
  async function decryptHybrid(encryptedData, options) {
705
- const { header, keyMaterial, encryptedMetadata, encryptedContent } = parseOQEFile(encryptedData);
706
- if (header.algorithmSuite !== ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
707
- throw new OQEError(
708
- "UNSUPPORTED_ALGORITHM",
709
- "This file was not encrypted with hybrid mode. Use password decryption."
710
- );
803
+ const parsed = parseOQEFile(encryptedData);
804
+ const { header } = parsed;
805
+ if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM) {
806
+ return decryptHybridV2(parsed, options);
807
+ }
808
+ if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
809
+ return decryptHybridV1Legacy(parsed, options);
711
810
  }
811
+ throw new OQEError(
812
+ "UNSUPPORTED_ALGORITHM",
813
+ "This file was not encrypted with hybrid mode. Use password decryption."
814
+ );
815
+ }
816
+ async function decryptHybridV2(parsed, options) {
817
+ const { header, keyMaterial, encryptedMetadata, encryptedContent } = parsed;
818
+ if (header.version !== OQE_FORMAT_VERSION_V2 || !header.contentIv) {
819
+ throw new OQEError("INVALID_HEADER", "v2 hybrid file missing content IV");
820
+ }
821
+ const km = parseHybridKeyMaterialV2(keyMaterial);
822
+ const x25519EpkHex = toHex(km.x25519EphemeralPk);
823
+ const kyberKemCtB64 = b64(km.kyberCiphertext);
824
+ const kyberShared = await kyberDecapsulate(kyberKemCtB64, options.recipientSecretKeys.kyberSecB64);
825
+ const sk = fromHex(options.recipientSecretKeys.x25519SecHex);
826
+ const x25519Shared = nacl3.scalarMult(sk, km.x25519EphemeralPk);
827
+ const kek = combinedFileKekV2(kyberShared, x25519Shared, x25519EpkHex, kyberKemCtB64);
828
+ kyberShared.fill(0);
829
+ x25519Shared.fill(0);
830
+ const contentKey = nacl3.secretbox.open(km.ckWrapped, km.ckWrapNonce, kek);
831
+ kek.fill(0);
832
+ if (!contentKey) {
833
+ throw new OQEError("KEY_UNWRAP_FAILED", "Could not unwrap content key \u2014 combined-KEK authentication failed");
834
+ }
835
+ const aad = headerAad(header);
836
+ try {
837
+ const metadata = await decryptSection(encryptedMetadata, contentKey, header.iv, aad, "metadata");
838
+ const plaintext = await aesDecrypt(encryptedContent, contentKey, header.contentIv, aad);
839
+ return buildResult(plaintext, metadata, "hybrid");
840
+ } finally {
841
+ contentKey.fill(0);
842
+ }
843
+ }
844
+ async function decryptHybridV1Legacy(parsed, options) {
845
+ const { header, keyMaterial, encryptedMetadata, encryptedContent } = parsed;
712
846
  const km = parseHybridKeyMaterial(keyMaterial);
713
847
  let contentKey = null;
714
848
  if (await isKyberAvailable()) {
@@ -717,54 +851,47 @@ async function decryptHybrid(encryptedData, options) {
717
851
  toB64(km.kyberCiphertext),
718
852
  options.recipientSecretKeys.kyberSecB64
719
853
  );
720
- const kyberKek = hkdfFlex2(kyberShared, "omnituum/fs/kyber", "wrap-content-key");
721
- const unwrapped = nacl2.secretbox.open(km.kyberWrappedKey, km.kyberNonce, kyberKek);
722
- if (unwrapped) {
723
- contentKey = unwrapped;
724
- console.log("[OQE] Decrypted content key via Kyber (post-quantum secure)");
725
- }
726
- } catch (e) {
727
- console.warn("[OQE] Kyber decapsulation failed, trying X25519:", e);
854
+ const kyberKek = hkdfFlex(kyberShared, "omnituum/fs/kyber", "wrap-content-key");
855
+ contentKey = nacl3.secretbox.open(km.kyberWrappedKey, km.kyberNonce, kyberKek);
856
+ } catch {
728
857
  }
729
858
  }
730
859
  if (!contentKey) {
731
860
  try {
732
- const ephPk = km.x25519EphemeralPk;
733
861
  const sk = fromHex(options.recipientSecretKeys.x25519SecHex);
734
- const x25519Shared = nacl2.scalarMult(sk, ephPk);
735
- const x25519Kek = hkdfFlex2(x25519Shared, "omnituum/fs/x25519", "wrap-content-key");
736
- const unwrapped = nacl2.secretbox.open(km.x25519WrappedKey, km.x25519Nonce, x25519Kek);
737
- if (unwrapped) {
738
- contentKey = unwrapped;
739
- console.log("[OQE] Decrypted content key via X25519 (classical)");
740
- }
741
- } catch (e) {
742
- console.warn("[OQE] X25519 decryption failed:", e);
862
+ const x25519Shared = nacl3.scalarMult(sk, km.x25519EphemeralPk);
863
+ const x25519Kek = hkdfFlex(x25519Shared, "omnituum/fs/x25519", "wrap-content-key");
864
+ contentKey = nacl3.secretbox.open(km.x25519WrappedKey, km.x25519Nonce, x25519Kek);
865
+ } catch {
743
866
  }
744
867
  }
745
868
  if (!contentKey) {
746
869
  throw new OQEError("KEY_UNWRAP_FAILED", "Could not unwrap content key with provided keys");
747
870
  }
748
- let metadata;
749
871
  try {
750
- const metadataBytes = await aesDecrypt(encryptedMetadata, contentKey, header.iv);
751
- metadata = parseMetadata(metadataBytes);
752
- } catch (e) {
753
- throw new OQEError("DECRYPTION_FAILED", "Failed to decrypt file metadata");
872
+ const metadata = await decryptSection(encryptedMetadata, contentKey, header.iv, void 0, "metadata");
873
+ const plaintext = await aesDecrypt(encryptedContent, contentKey, header.iv);
874
+ return buildResult(plaintext, metadata, "hybrid");
875
+ } finally {
876
+ contentKey.fill(0);
754
877
  }
755
- let plaintext;
878
+ }
879
+ async function decryptSection(ciphertext, key, iv, aad, what) {
756
880
  try {
757
- plaintext = await aesDecrypt(encryptedContent, contentKey, header.iv);
758
- } catch (e) {
759
- throw new OQEError("DECRYPTION_FAILED", "Failed to decrypt file content");
881
+ const bytes = await aesDecrypt(ciphertext, key, iv, aad);
882
+ return parseMetadata(bytes);
883
+ } catch {
884
+ throw new OQEError("DECRYPTION_FAILED", `Failed to decrypt file ${what}`);
760
885
  }
886
+ }
887
+ function buildResult(plaintext, metadata, mode) {
761
888
  return {
762
889
  data: plaintext,
763
890
  filename: metadata.filename,
764
891
  mimeType: metadata.mimeType,
765
892
  originalSize: metadata.originalSize,
766
893
  metadata,
767
- mode: "hybrid"
894
+ mode
768
895
  };
769
896
  }
770
897
  async function decryptPassword(encryptedData, options) {
@@ -786,27 +913,27 @@ async function decryptPassword(encryptedData, options) {
786
913
  hashLength: 32,
787
914
  saltLength: km.salt.length
788
915
  });
789
- let metadata;
790
- try {
791
- const metadataBytes = await aesDecrypt(encryptedMetadata, contentKey, header.iv);
792
- metadata = parseMetadata(metadataBytes);
793
- } catch (e) {
794
- throw new OQEError("PASSWORD_WRONG", "Incorrect password or corrupted file");
795
- }
796
- let plaintext;
916
+ const isV2 = header.version === OQE_FORMAT_VERSION_V2;
917
+ const aad = isV2 ? headerAad(header) : void 0;
918
+ const contentIv = isV2 && header.contentIv ? header.contentIv : header.iv;
797
919
  try {
798
- plaintext = await aesDecrypt(encryptedContent, contentKey, header.iv);
799
- } catch (e) {
800
- throw new OQEError("DECRYPTION_FAILED", "Failed to decrypt file content");
920
+ let metadata;
921
+ try {
922
+ const metadataBytes = await aesDecrypt(encryptedMetadata, contentKey, header.iv, aad);
923
+ metadata = parseMetadata(metadataBytes);
924
+ } catch {
925
+ throw new OQEError("PASSWORD_WRONG", "Incorrect password or corrupted file");
926
+ }
927
+ let plaintext;
928
+ try {
929
+ plaintext = await aesDecrypt(encryptedContent, contentKey, contentIv, aad);
930
+ } catch {
931
+ throw new OQEError("DECRYPTION_FAILED", "Failed to decrypt file content");
932
+ }
933
+ return buildResult(plaintext, metadata, "password");
934
+ } finally {
935
+ contentKey.fill(0);
801
936
  }
802
- return {
803
- data: plaintext,
804
- filename: metadata.filename,
805
- mimeType: metadata.mimeType,
806
- originalSize: metadata.originalSize,
807
- metadata,
808
- mode: "password"
809
- };
810
937
  }
811
938
  async function decryptFile(encryptedData, options) {
812
939
  const data = await toUint8Array(encryptedData);
@@ -836,9 +963,12 @@ async function inspectOQEFile(data) {
836
963
  const { header } = parseOQEFile(bytes);
837
964
  let mode;
838
965
  let algorithm;
839
- if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
966
+ if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM) {
967
+ mode = "hybrid";
968
+ algorithm = "X25519 + ML-KEM-1024 + AES-256-GCM (AND-combined)";
969
+ } else if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
840
970
  mode = "hybrid";
841
- algorithm = "X25519 + Kyber768 + AES-256-GCM";
971
+ algorithm = "X25519 + Kyber + AES-256-GCM (legacy, either-key)";
842
972
  } else {
843
973
  mode = "password";
844
974
  algorithm = "Argon2id + AES-256-GCM";
@@ -1060,4 +1190,4 @@ function isDragDropSupported() {
1060
1190
  return "draggable" in div || "ondragstart" in div && "ondrop" in div;
1061
1191
  }
1062
1192
 
1063
- export { AES_GCM_IV_SIZE, AES_GCM_TAG_SIZE, AES_KEY_SIZE, ALGORITHM_SUITES, DEFAULT_ARGON2ID_PARAMS, MIN_ARGON2ID_PARAMS, OQEError, OQE_EXTENSION, OQE_FORMAT_VERSION, OQE_HEADER_SIZE, OQE_MAGIC, OQE_MIME_TYPE, STREAM_CHUNK_SIZE, addOQEExtension, aesDecrypt, aesDecryptCombined, aesDecryptStreaming, aesEncrypt, aesEncryptCombined, aesEncryptStreaming, assembleOQEFile, benchmarkArgon2, bytesToDataURL, copyToClipboard, createDropZone, createObjectURL, decryptFile, decryptFileForSelf, decryptFileWithPassword, decryptResultToBlob, deriveKeyFromPassword, downloadBlob, downloadBytes, downloadDecryptedFile, downloadEncryptedFile, encryptFile, encryptFileForSelf, encryptFileWithPassword, encryptResultToBlob, estimateArgon2Params, exportAesKey, formatFileSize, generateAesKey, generateArgon2Salt, getAlgorithmName, getFileInfo, importAesKey, inspectOQEFile, isArgon2Available, isBrowser, isDragDropSupported, isFileAPIAvailable, isOQEFile, isWebCryptoAvailable, openFilePicker, openFileToEncrypt, openOQEFilePicker, parseHybridKeyMaterial, parseMetadata, parseOQEFile, parseOQEHeader, parsePasswordKeyMaterial, readFile, readFileAsDataURL, readFileAsText, removeOQEExtension, serializeHybridKeyMaterial, serializeMetadata, serializePasswordKeyMaterial, toUint8Array, verifyPassword, writeOQEHeader };
1193
+ export { AES_GCM_IV_SIZE, AES_GCM_TAG_SIZE, AES_KEY_SIZE, ALGORITHM_SUITES, DEFAULT_ARGON2ID_PARAMS, MIN_ARGON2ID_PARAMS, OQEError, OQE_EXTENSION, OQE_FORMAT_VERSION, OQE_FORMAT_VERSION_V1, OQE_FORMAT_VERSION_V2, OQE_HEADER_SIZE, OQE_HEADER_SIZE_V1, OQE_HEADER_SIZE_V2, OQE_MAGIC, OQE_MIME_TYPE, STREAM_CHUNK_SIZE, SUPPORTED_OQE_VERSIONS, addOQEExtension, aesDecrypt, aesDecryptCombined, aesDecryptStreaming, aesEncrypt, aesEncryptCombined, aesEncryptStreaming, assembleOQEFile, benchmarkArgon2, bytesToDataURL, combinedFileKekV2, copyToClipboard, createDropZone, createObjectURL, decryptFile, decryptFileForSelf, decryptFileWithPassword, decryptResultToBlob, deriveKeyFromPassword, downloadBlob, downloadBytes, downloadDecryptedFile, downloadEncryptedFile, encryptFile, encryptFileForSelf, encryptFileWithPassword, encryptResultToBlob, estimateArgon2Params, exportAesKey, formatFileSize, generateAesKey, generateArgon2Salt, getAlgorithmName, getFileInfo, importAesKey, inspectOQEFile, isArgon2Available, isBrowser, isDragDropSupported, isFileAPIAvailable, isOQEFile, isWebCryptoAvailable, openFilePicker, openFileToEncrypt, openOQEFilePicker, oqeHeaderSize, parseHybridKeyMaterial, parseHybridKeyMaterialV2, parseMetadata, parseOQEFile, parseOQEHeader, parsePasswordKeyMaterial, readFile, readFileAsDataURL, readFileAsText, removeOQEExtension, serializeHybridKeyMaterial, serializeHybridKeyMaterialV2, serializeMetadata, serializePasswordKeyMaterial, toUint8Array, verifyPassword, writeOQEHeader };