@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
package/src/fs/decrypt.ts CHANGED
@@ -7,13 +7,17 @@
7
7
  import nacl from 'tweetnacl';
8
8
  import {
9
9
  fromHex,
10
+ toHex,
10
11
  hkdfSha256,
11
12
  toB64,
13
+ b64,
12
14
  u8,
13
15
  } from '../crypto/primitives';
14
16
  import { kyberDecapsulate, isKyberAvailable } from '../crypto/kyber';
15
17
  import {
16
18
  ALGORITHM_SUITES,
19
+ OQE_FORMAT_VERSION_V2,
20
+ OQEHeader,
17
21
  OQEMetadata,
18
22
  HybridDecryptOptions,
19
23
  PasswordDecryptOptions,
@@ -23,13 +27,16 @@ import {
23
27
  FileInput,
24
28
  toUint8Array,
25
29
  } from './types';
30
+ import { combinedFileKekV2 } from './encrypt';
26
31
  import { deriveKeyFromPassword, isArgon2Available } from './argon2';
27
32
  import { aesDecrypt } from './aes';
28
33
  import {
29
34
  parseOQEFile,
30
35
  parseHybridKeyMaterial,
36
+ parseHybridKeyMaterialV2,
31
37
  parsePasswordKeyMaterial,
32
38
  parseMetadata,
39
+ writeOQEHeader,
33
40
  } from './format';
34
41
 
35
42
  // ═══════════════════════════════════════════════════════════════════════════
@@ -40,69 +47,120 @@ function hkdfFlex(ikm: Uint8Array, salt: string, info: string): Uint8Array {
40
47
  return hkdfSha256(ikm, { salt: u8(salt), info: u8(info), length: 32 });
41
48
  }
42
49
 
50
+ /**
51
+ * Reconstruct the AEAD associated data for a parsed v2 header. Re-serializing
52
+ * the parsed header yields the exact bytes bound at encryption time, so any
53
+ * mutation of version/suite/flags/lengths/IVs makes AES-GCM authentication fail.
54
+ */
55
+ function headerAad(header: OQEHeader): Uint8Array {
56
+ return writeOQEHeader(header);
57
+ }
58
+
43
59
  // ═══════════════════════════════════════════════════════════════════════════
44
60
  // HYBRID MODE DECRYPTION
45
61
  // ═══════════════════════════════════════════════════════════════════════════
46
62
 
47
63
  /**
48
- * Decrypt an OQE file using hybrid X25519 + Kyber768.
49
- * Tries Kyber first (post-quantum), falls back to X25519 (classical).
64
+ * Decrypt an OQE hybrid file. Dispatches by suite:
65
+ * - v2 (ML-KEM-1024, suite 0x03): single AND-combined KEK — both the X25519 and
66
+ * ML-KEM secrets are required and there is no per-primitive fallback.
67
+ * - v1 legacy (Kyber, suite 0x01): read-only compatibility path where either
68
+ * secret alone unwraps the content key (the weakness v2 exists to fix).
50
69
  */
51
70
  async function decryptHybrid(
52
71
  encryptedData: Uint8Array,
53
72
  options: HybridDecryptOptions
54
73
  ): Promise<OQEDecryptResult> {
55
- // Parse file structure
56
- const { header, keyMaterial, encryptedMetadata, encryptedContent } = parseOQEFile(encryptedData);
74
+ const parsed = parseOQEFile(encryptedData);
75
+ const { header } = parsed;
57
76
 
58
- // Verify algorithm
59
- if (header.algorithmSuite !== ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
60
- throw new OQEError(
61
- 'UNSUPPORTED_ALGORITHM',
62
- 'This file was not encrypted with hybrid mode. Use password decryption.'
63
- );
77
+ if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM) {
78
+ return decryptHybridV2(parsed, options);
79
+ }
80
+ if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
81
+ return decryptHybridV1Legacy(parsed, options);
82
+ }
83
+ throw new OQEError(
84
+ 'UNSUPPORTED_ALGORITHM',
85
+ 'This file was not encrypted with hybrid mode. Use password decryption.'
86
+ );
87
+ }
88
+
89
+ type ParsedFile = ReturnType<typeof parseOQEFile>;
90
+
91
+ /** v2 AND-combined hybrid decryption (both secrets required, header AAD-bound). */
92
+ async function decryptHybridV2(
93
+ parsed: ParsedFile,
94
+ options: HybridDecryptOptions
95
+ ): Promise<OQEDecryptResult> {
96
+ const { header, keyMaterial, encryptedMetadata, encryptedContent } = parsed;
97
+ if (header.version !== OQE_FORMAT_VERSION_V2 || !header.contentIv) {
98
+ throw new OQEError('INVALID_HEADER', 'v2 hybrid file missing content IV');
99
+ }
100
+
101
+ const km = parseHybridKeyMaterialV2(keyMaterial);
102
+ // Must match encryptHybrid's transcript exactly: plain hex, no "0x" prefix.
103
+ const x25519EpkHex = toHex(km.x25519EphemeralPk);
104
+ const kyberKemCtB64 = b64(km.kyberCiphertext);
105
+
106
+ // ML-KEM decapsulation (post-quantum half). No classical fallback in v2.
107
+ const kyberShared = await kyberDecapsulate(kyberKemCtB64, options.recipientSecretKeys.kyberSecB64);
108
+
109
+ // X25519 ECDH (classical half).
110
+ const sk = fromHex(options.recipientSecretKeys.x25519SecHex);
111
+ const x25519Shared = nacl.scalarMult(sk, km.x25519EphemeralPk);
112
+
113
+ const kek = combinedFileKekV2(kyberShared, x25519Shared, x25519EpkHex, kyberKemCtB64);
114
+ kyberShared.fill(0);
115
+ x25519Shared.fill(0);
116
+
117
+ const contentKey = nacl.secretbox.open(km.ckWrapped, km.ckWrapNonce, kek);
118
+ kek.fill(0);
119
+ if (!contentKey) {
120
+ throw new OQEError('KEY_UNWRAP_FAILED', 'Could not unwrap content key — combined-KEK authentication failed');
121
+ }
122
+
123
+ const aad = headerAad(header);
124
+ try {
125
+ const metadata = await decryptSection(encryptedMetadata, contentKey, header.iv, aad, 'metadata');
126
+ const plaintext = await aesDecrypt(encryptedContent, contentKey, header.contentIv, aad);
127
+ return buildResult(plaintext, metadata, 'hybrid');
128
+ } finally {
129
+ contentKey.fill(0);
64
130
  }
131
+ }
65
132
 
66
- // Parse key material
133
+ /** LEGACY read-only: v1 either-key hybrid. Single IV, no AAD. */
134
+ async function decryptHybridV1Legacy(
135
+ parsed: ParsedFile,
136
+ options: HybridDecryptOptions
137
+ ): Promise<OQEDecryptResult> {
138
+ const { header, keyMaterial, encryptedMetadata, encryptedContent } = parsed;
67
139
  const km = parseHybridKeyMaterial(keyMaterial);
68
140
 
69
141
  let contentKey: Uint8Array | null = null;
70
142
 
71
- // Try Kyber first (post-quantum path)
72
143
  if (await isKyberAvailable()) {
73
144
  try {
74
- // Kyber decapsulate expects base64 ciphertext
75
145
  const kyberShared = await kyberDecapsulate(
76
146
  toB64(km.kyberCiphertext),
77
147
  options.recipientSecretKeys.kyberSecB64
78
148
  );
79
149
  const kyberKek = hkdfFlex(kyberShared, 'omnituum/fs/kyber', 'wrap-content-key');
80
- const unwrapped = nacl.secretbox.open(km.kyberWrappedKey, km.kyberNonce, kyberKek);
81
-
82
- if (unwrapped) {
83
- contentKey = unwrapped;
84
- console.log('[OQE] Decrypted content key via Kyber (post-quantum secure)');
85
- }
86
- } catch (e) {
87
- console.warn('[OQE] Kyber decapsulation failed, trying X25519:', e);
150
+ contentKey = nacl.secretbox.open(km.kyberWrappedKey, km.kyberNonce, kyberKek);
151
+ } catch {
152
+ // Fall through to the classical wrap.
88
153
  }
89
154
  }
90
155
 
91
- // Fall back to X25519 (classical path)
92
156
  if (!contentKey) {
93
157
  try {
94
- const ephPk = km.x25519EphemeralPk;
95
158
  const sk = fromHex(options.recipientSecretKeys.x25519SecHex);
96
- const x25519Shared = nacl.scalarMult(sk, ephPk);
159
+ const x25519Shared = nacl.scalarMult(sk, km.x25519EphemeralPk);
97
160
  const x25519Kek = hkdfFlex(x25519Shared, 'omnituum/fs/x25519', 'wrap-content-key');
98
- const unwrapped = nacl.secretbox.open(km.x25519WrappedKey, km.x25519Nonce, x25519Kek);
99
-
100
- if (unwrapped) {
101
- contentKey = unwrapped;
102
- console.log('[OQE] Decrypted content key via X25519 (classical)');
103
- }
104
- } catch (e) {
105
- console.warn('[OQE] X25519 decryption failed:', e);
161
+ contentKey = nacl.secretbox.open(km.x25519WrappedKey, km.x25519Nonce, x25519Kek);
162
+ } catch {
163
+ // Handled below.
106
164
  }
107
165
  }
108
166
 
@@ -110,30 +168,43 @@ async function decryptHybrid(
110
168
  throw new OQEError('KEY_UNWRAP_FAILED', 'Could not unwrap content key with provided keys');
111
169
  }
112
170
 
113
- // Decrypt metadata
114
- let metadata: OQEMetadata;
115
171
  try {
116
- const metadataBytes = await aesDecrypt(encryptedMetadata, contentKey, header.iv);
117
- metadata = parseMetadata(metadataBytes);
118
- } catch (e) {
119
- throw new OQEError('DECRYPTION_FAILED', 'Failed to decrypt file metadata');
172
+ // v1 reused header.iv for both sections; preserved for read compatibility.
173
+ const metadata = await decryptSection(encryptedMetadata, contentKey, header.iv, undefined, 'metadata');
174
+ const plaintext = await aesDecrypt(encryptedContent, contentKey, header.iv);
175
+ return buildResult(plaintext, metadata, 'hybrid');
176
+ } finally {
177
+ contentKey.fill(0);
120
178
  }
179
+ }
121
180
 
122
- // Decrypt file content
123
- let plaintext: Uint8Array;
181
+ async function decryptSection(
182
+ ciphertext: Uint8Array,
183
+ key: Uint8Array,
184
+ iv: Uint8Array,
185
+ aad: Uint8Array | undefined,
186
+ what: 'metadata'
187
+ ): Promise<OQEMetadata> {
124
188
  try {
125
- plaintext = await aesDecrypt(encryptedContent, contentKey, header.iv);
126
- } catch (e) {
127
- throw new OQEError('DECRYPTION_FAILED', 'Failed to decrypt file content');
189
+ const bytes = await aesDecrypt(ciphertext, key, iv, aad);
190
+ return parseMetadata(bytes);
191
+ } catch {
192
+ throw new OQEError('DECRYPTION_FAILED', `Failed to decrypt file ${what}`);
128
193
  }
194
+ }
129
195
 
196
+ function buildResult(
197
+ plaintext: Uint8Array,
198
+ metadata: OQEMetadata,
199
+ mode: 'hybrid' | 'password'
200
+ ): OQEDecryptResult {
130
201
  return {
131
202
  data: plaintext,
132
203
  filename: metadata.filename,
133
204
  mimeType: metadata.mimeType,
134
205
  originalSize: metadata.originalSize,
135
206
  metadata,
136
- mode: 'hybrid',
207
+ mode,
137
208
  };
138
209
  }
139
210
 
@@ -176,31 +247,34 @@ async function decryptPassword(
176
247
  saltLength: km.salt.length,
177
248
  });
178
249
 
179
- // Decrypt metadata (also verifies password)
180
- let metadata: OQEMetadata;
181
- try {
182
- const metadataBytes = await aesDecrypt(encryptedMetadata, contentKey, header.iv);
183
- metadata = parseMetadata(metadataBytes);
184
- } catch (e) {
185
- throw new OQEError('PASSWORD_WRONG', 'Incorrect password or corrupted file');
186
- }
250
+ // v2 binds the header as AAD and uses a distinct content IV; v1 reused
251
+ // header.iv for both sections and had no AAD.
252
+ const isV2 = header.version === OQE_FORMAT_VERSION_V2;
253
+ const aad = isV2 ? headerAad(header) : undefined;
254
+ const contentIv = isV2 && header.contentIv ? header.contentIv : header.iv;
187
255
 
188
- // Decrypt file content
189
- let plaintext: Uint8Array;
190
256
  try {
191
- plaintext = await aesDecrypt(encryptedContent, contentKey, header.iv);
192
- } catch (e) {
193
- throw new OQEError('DECRYPTION_FAILED', 'Failed to decrypt file content');
194
- }
257
+ // Decrypt metadata (also verifies password).
258
+ let metadata: OQEMetadata;
259
+ try {
260
+ const metadataBytes = await aesDecrypt(encryptedMetadata, contentKey, header.iv, aad);
261
+ metadata = parseMetadata(metadataBytes);
262
+ } catch {
263
+ throw new OQEError('PASSWORD_WRONG', 'Incorrect password or corrupted file');
264
+ }
195
265
 
196
- return {
197
- data: plaintext,
198
- filename: metadata.filename,
199
- mimeType: metadata.mimeType,
200
- originalSize: metadata.originalSize,
201
- metadata,
202
- mode: 'password',
203
- };
266
+ // Decrypt file content.
267
+ let plaintext: Uint8Array;
268
+ try {
269
+ plaintext = await aesDecrypt(encryptedContent, contentKey, contentIv, aad);
270
+ } catch {
271
+ throw new OQEError('DECRYPTION_FAILED', 'Failed to decrypt file content');
272
+ }
273
+
274
+ return buildResult(plaintext, metadata, 'password');
275
+ } finally {
276
+ contentKey.fill(0);
277
+ }
204
278
  }
205
279
 
206
280
  // ═══════════════════════════════════════════════════════════════════════════
@@ -302,9 +376,12 @@ export async function inspectOQEFile(data: FileInput): Promise<OQEFileInfo> {
302
376
  let mode: 'hybrid' | 'password';
303
377
  let algorithm: string;
304
378
 
305
- if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
379
+ if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM) {
380
+ mode = 'hybrid';
381
+ algorithm = 'X25519 + ML-KEM-1024 + AES-256-GCM (AND-combined)';
382
+ } else if (header.algorithmSuite === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
306
383
  mode = 'hybrid';
307
- algorithm = 'X25519 + Kyber768 + AES-256-GCM';
384
+ algorithm = 'X25519 + Kyber + AES-256-GCM (legacy, either-key)';
308
385
  } else {
309
386
  mode = 'password';
310
387
  algorithm = 'Argon2id + AES-256-GCM';
package/src/fs/encrypt.ts CHANGED
@@ -12,17 +12,18 @@ import {
12
12
  rand12,
13
13
  toHex,
14
14
  fromHex,
15
+ b64,
15
16
  hkdfSha256,
16
17
  sha256,
17
18
  u8,
18
19
  } from '../crypto/primitives';
19
20
  import { kyberEncapsulate, isKyberAvailable } from '../crypto/kyber';
20
21
  import {
21
- OQE_FORMAT_VERSION,
22
+ OQE_FORMAT_VERSION_V2,
22
23
  ALGORITHM_SUITES,
23
24
  OQEMetadata,
24
25
  OQEHeader,
25
- HybridKeyMaterial,
26
+ HybridKeyMaterialV2,
26
27
  PasswordKeyMaterial,
27
28
  HybridEncryptOptions,
28
29
  PasswordEncryptOptions,
@@ -34,35 +35,69 @@ import {
34
35
  DEFAULT_ARGON2ID_PARAMS,
35
36
  } from './types';
36
37
  import { deriveKeyFromPassword, generateArgon2Salt, isArgon2Available } from './argon2';
37
- import { aesEncrypt } from './aes';
38
+ import { aesEncrypt, AES_GCM_TAG_SIZE } from './aes';
38
39
  import {
39
- serializeHybridKeyMaterial,
40
+ serializeHybridKeyMaterialV2,
40
41
  serializePasswordKeyMaterial,
41
42
  serializeMetadata,
43
+ writeOQEHeader,
42
44
  assembleOQEFile,
43
45
  addOQEExtension,
44
46
  } from './format';
45
47
 
48
+ // AES-GCM tag length is fixed at 16 bytes, so GCM ciphertext length is known
49
+ // before encryption. This lets us build (and thus AAD-bind) the full header,
50
+ // including metadataLength, prior to the actual encrypt calls.
51
+ const AES_GCM_TAG_LEN = AES_GCM_TAG_SIZE;
52
+
46
53
  // ═══════════════════════════════════════════════════════════════════════════
47
54
  // HELPERS
48
55
  // ═══════════════════════════════════════════════════════════════════════════
49
56
 
50
- function hkdfFlex(ikm: Uint8Array, salt: string, info: string): Uint8Array {
51
- return hkdfSha256(ikm, { salt: u8(salt), info: u8(info), length: 32 });
52
- }
53
-
54
57
  function computeIdentityHash(publicKeyHex: string): string {
55
58
  const hash = sha256(fromHex(publicKeyHex));
56
59
  return toHex(hash).slice(0, 16); // First 8 bytes = 16 hex chars
57
60
  }
58
61
 
62
+ /**
63
+ * Derive the v2 AND-combined KEK for file encryption. Requires BOTH shared
64
+ * secrets; the envelope's own KEM values are bound into the HKDF info so a
65
+ * spliced ephemeral key or Kyber ciphertext derives a different KEK and the
66
+ * wrap fails authentication (X-Wing-style transcript binding). Domain-separated
67
+ * from the message-envelope KEK via the "omnituum/fs/hybrid-v2" salt.
68
+ */
69
+ export function combinedFileKekV2(
70
+ kyberShared: Uint8Array,
71
+ x25519Shared: Uint8Array,
72
+ x25519EpkHex: string,
73
+ kyberKemCtB64: string
74
+ ): Uint8Array {
75
+ const ikm = new Uint8Array(kyberShared.length + x25519Shared.length);
76
+ ikm.set(kyberShared, 0);
77
+ ikm.set(x25519Shared, kyberShared.length);
78
+ try {
79
+ return hkdfSha256(ikm, {
80
+ salt: u8('omnituum/fs/hybrid-v2'),
81
+ info: u8(`wrap-content-key|${x25519EpkHex}|${kyberKemCtB64}`),
82
+ length: 32,
83
+ });
84
+ } finally {
85
+ ikm.fill(0);
86
+ }
87
+ }
88
+
59
89
  // ═══════════════════════════════════════════════════════════════════════════
60
90
  // HYBRID MODE ENCRYPTION
61
91
  // ═══════════════════════════════════════════════════════════════════════════
62
92
 
63
93
  /**
64
- * Encrypt a file using hybrid X25519 + Kyber768 encryption.
65
- * Provides post-quantum security through dual-algorithm key wrapping.
94
+ * Encrypt a file using hybrid X25519 + ML-KEM-1024 encryption (OQE v2).
95
+ *
96
+ * The content key is wrapped exactly once, under a KEK derived from BOTH shared
97
+ * secrets together (AND-combined) — breaking either primitive alone is
98
+ * insufficient to unwrap. Metadata and content are encrypted under separate
99
+ * AES-GCM IVs, and the full serialized header (version, suite, flags, lengths,
100
+ * both IVs) is bound as AEAD associated data.
66
101
  */
67
102
  async function encryptHybrid(
68
103
  plaintext: Uint8Array,
@@ -77,65 +112,74 @@ async function encryptHybrid(
77
112
  // 1. Generate random content key (32 bytes for AES-256)
78
113
  const contentKey = rand32();
79
114
 
80
- // 2. Generate IV for AES-GCM
81
- const iv = rand12();
82
-
83
- // 3. Wrap content key with X25519 ECDH
84
- const x25519EphKp = nacl.box.keyPair();
85
- const recipientX25519Pk = fromHex(options.recipientPublicKeys.x25519PubHex);
86
- const x25519Shared = nacl.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
87
- const x25519Kek = hkdfFlex(x25519Shared, 'omnituum/fs/x25519', 'wrap-content-key');
88
- const x25519Nonce = rand24();
89
- const x25519WrappedKey = nacl.secretbox(contentKey, x25519Nonce, x25519Kek);
90
-
91
- // 4. Wrap content key with Kyber KEM
92
- const kyberResult = await kyberEncapsulate(options.recipientPublicKeys.kyberPubB64);
93
- const kyberKek = hkdfFlex(kyberResult.sharedSecret, 'omnituum/fs/kyber', 'wrap-content-key');
94
- const kyberNonce = rand24();
95
- const kyberWrappedKey = nacl.secretbox(contentKey, kyberNonce, kyberKek);
96
-
97
- // 5. Serialize key material
98
- const keyMaterial: HybridKeyMaterial = {
99
- x25519EphemeralPk: x25519EphKp.publicKey,
100
- x25519Nonce,
101
- x25519WrappedKey,
102
- kyberCiphertext: kyberResult.ciphertext,
103
- kyberNonce,
104
- kyberWrappedKey,
105
- };
106
- const keyMaterialBytes = serializeHybridKeyMaterial(keyMaterial);
107
-
108
- // 6. Encrypt metadata
109
- const metadataBytes = serializeMetadata(metadata);
110
- const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, iv);
111
-
112
- // 7. Encrypt file content
113
- const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, iv);
114
-
115
- // 8. Build header
116
- const header: OQEHeader = {
117
- version: OQE_FORMAT_VERSION,
118
- algorithmSuite: ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM,
119
- flags: 0,
120
- metadataLength: encryptedMetadata.length,
121
- keyMaterialLength: keyMaterialBytes.length,
122
- iv,
123
- };
124
-
125
- // 9. Assemble complete file
126
- const fileData = assembleOQEFile({
127
- header,
128
- keyMaterial: keyMaterialBytes,
129
- encryptedMetadata,
130
- encryptedContent,
131
- });
132
-
133
- return {
134
- data: fileData,
135
- filename: addOQEExtension(metadata.filename),
136
- metadata,
137
- mode: 'hybrid',
138
- };
115
+ try {
116
+ // 2. Separate IVs for metadata and content — never reuse a (key, nonce) pair.
117
+ const metadataIv = rand12();
118
+ const contentIv = rand12();
119
+
120
+ // 3. X25519 ECDH shared secret (ephemeral)
121
+ const x25519EphKp = nacl.box.keyPair();
122
+ const recipientX25519Pk = fromHex(options.recipientPublicKeys.x25519PubHex);
123
+ const x25519Shared = nacl.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
124
+ const x25519EpkHex = toHex(x25519EphKp.publicKey);
125
+
126
+ // 4. ML-KEM-1024 shared secret
127
+ const kyberResult = await kyberEncapsulate(options.recipientPublicKeys.kyberPubB64);
128
+ const kyberKemCtB64 = b64(kyberResult.ciphertext);
129
+
130
+ // 5. Single wrap under the AND-combined, transcript-bound KEK.
131
+ const kek = combinedFileKekV2(kyberResult.sharedSecret, x25519Shared, x25519EpkHex, kyberKemCtB64);
132
+ const ckWrapNonce = rand24();
133
+ const ckWrapped = nacl.secretbox(contentKey, ckWrapNonce, kek);
134
+ kek.fill(0);
135
+ x25519Shared.fill(0);
136
+ kyberResult.sharedSecret.fill(0);
137
+ x25519EphKp.secretKey.fill(0);
138
+
139
+ // 6. Serialize key material
140
+ const keyMaterial: HybridKeyMaterialV2 = {
141
+ x25519EphemeralPk: x25519EphKp.publicKey,
142
+ kyberCiphertext: kyberResult.ciphertext,
143
+ ckWrapNonce,
144
+ ckWrapped,
145
+ };
146
+ const keyMaterialBytes = serializeHybridKeyMaterialV2(keyMaterial);
147
+
148
+ // 7. Build header up-front so it can be used as AAD. GCM ciphertext length
149
+ // is plaintext length + tag, so metadataLength is known before encrypting.
150
+ const metadataBytes = serializeMetadata(metadata);
151
+ const header: OQEHeader = {
152
+ version: OQE_FORMAT_VERSION_V2,
153
+ algorithmSuite: ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM,
154
+ flags: 0,
155
+ metadataLength: metadataBytes.length + AES_GCM_TAG_LEN,
156
+ keyMaterialLength: keyMaterialBytes.length,
157
+ iv: metadataIv,
158
+ contentIv,
159
+ };
160
+ const aad = writeOQEHeader(header);
161
+
162
+ // 8. Encrypt metadata and content under distinct IVs, header bound as AAD.
163
+ const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, metadataIv, aad);
164
+ const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, contentIv, aad);
165
+
166
+ // 9. Assemble complete file
167
+ const fileData = assembleOQEFile({
168
+ header,
169
+ keyMaterial: keyMaterialBytes,
170
+ encryptedMetadata,
171
+ encryptedContent,
172
+ });
173
+
174
+ return {
175
+ data: fileData,
176
+ filename: addOQEExtension(metadata.filename),
177
+ metadata,
178
+ mode: 'hybrid',
179
+ };
180
+ } finally {
181
+ contentKey.fill(0);
182
+ }
139
183
  }
140
184
 
141
185
  // ═══════════════════════════════════════════════════════════════════════════
@@ -168,49 +212,54 @@ async function encryptPassword(
168
212
  // 2. Derive content key from password
169
213
  const contentKey = await deriveKeyFromPassword(options.password, salt, params);
170
214
 
171
- // 3. Generate IV for AES-GCM
172
- const iv = rand12();
173
-
174
- // 4. Serialize key material (Argon2 params + salt)
175
- const keyMaterial: PasswordKeyMaterial = {
176
- salt,
177
- memoryCost: params.memoryCost,
178
- timeCost: params.timeCost,
179
- parallelism: params.parallelism,
180
- };
181
- const keyMaterialBytes = serializePasswordKeyMaterial(keyMaterial);
182
-
183
- // 5. Encrypt metadata
184
- const metadataBytes = serializeMetadata(metadata);
185
- const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, iv);
186
-
187
- // 6. Encrypt file content
188
- const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, iv);
189
-
190
- // 7. Build header
191
- const header: OQEHeader = {
192
- version: OQE_FORMAT_VERSION,
193
- algorithmSuite: ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM,
194
- flags: 0,
195
- metadataLength: encryptedMetadata.length,
196
- keyMaterialLength: keyMaterialBytes.length,
197
- iv,
198
- };
199
-
200
- // 8. Assemble complete file
201
- const fileData = assembleOQEFile({
202
- header,
203
- keyMaterial: keyMaterialBytes,
204
- encryptedMetadata,
205
- encryptedContent,
206
- });
207
-
208
- return {
209
- data: fileData,
210
- filename: addOQEExtension(metadata.filename),
211
- metadata,
212
- mode: 'password',
213
- };
215
+ try {
216
+ // 3. Separate IVs for metadata and content — never reuse a (key, nonce) pair.
217
+ const metadataIv = rand12();
218
+ const contentIv = rand12();
219
+
220
+ // 4. Serialize key material (Argon2 params + salt)
221
+ const keyMaterial: PasswordKeyMaterial = {
222
+ salt,
223
+ memoryCost: params.memoryCost,
224
+ timeCost: params.timeCost,
225
+ parallelism: params.parallelism,
226
+ };
227
+ const keyMaterialBytes = serializePasswordKeyMaterial(keyMaterial);
228
+
229
+ // 5. Build header up-front for AAD binding.
230
+ const metadataBytes = serializeMetadata(metadata);
231
+ const header: OQEHeader = {
232
+ version: OQE_FORMAT_VERSION_V2,
233
+ algorithmSuite: ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM,
234
+ flags: 0,
235
+ metadataLength: metadataBytes.length + AES_GCM_TAG_LEN,
236
+ keyMaterialLength: keyMaterialBytes.length,
237
+ iv: metadataIv,
238
+ contentIv,
239
+ };
240
+ const aad = writeOQEHeader(header);
241
+
242
+ // 6. Encrypt metadata and content under distinct IVs, header bound as AAD.
243
+ const { ciphertext: encryptedMetadata } = await aesEncrypt(metadataBytes, contentKey, metadataIv, aad);
244
+ const { ciphertext: encryptedContent } = await aesEncrypt(plaintext, contentKey, contentIv, aad);
245
+
246
+ // 7. Assemble complete file
247
+ const fileData = assembleOQEFile({
248
+ header,
249
+ keyMaterial: keyMaterialBytes,
250
+ encryptedMetadata,
251
+ encryptedContent,
252
+ });
253
+
254
+ return {
255
+ data: fileData,
256
+ filename: addOQEExtension(metadata.filename),
257
+ metadata,
258
+ mode: 'password',
259
+ };
260
+ } finally {
261
+ contentKey.fill(0);
262
+ }
214
263
  }
215
264
 
216
265
  // ═══════════════════════════════════════════════════════════════════════════