@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/src/fs/format.ts CHANGED
@@ -8,17 +8,26 @@
8
8
  import { textEncoder, textDecoder } from '../crypto/primitives';
9
9
  import {
10
10
  OQE_MAGIC,
11
- OQE_FORMAT_VERSION,
12
- OQE_HEADER_SIZE,
11
+ OQE_FORMAT_VERSION_V1,
12
+ OQE_FORMAT_VERSION_V2,
13
+ OQE_HEADER_SIZE_V1,
14
+ OQE_HEADER_SIZE_V2,
15
+ SUPPORTED_OQE_VERSIONS,
13
16
  ALGORITHM_SUITES,
14
17
  AlgorithmSuiteId,
15
18
  OQEHeader,
16
19
  OQEMetadata,
17
20
  HybridKeyMaterial,
21
+ HybridKeyMaterialV2,
18
22
  PasswordKeyMaterial,
19
23
  OQEError,
20
24
  } from './types';
21
25
 
26
+ /** Byte length of a section's fixed header for the given format version. */
27
+ export function oqeHeaderSize(version: number): number {
28
+ return version === OQE_FORMAT_VERSION_V2 ? OQE_HEADER_SIZE_V2 : OQE_HEADER_SIZE_V1;
29
+ }
30
+
22
31
  // ═══════════════════════════════════════════════════════════════════════════
23
32
  // BINARY UTILITIES
24
33
  // ═══════════════════════════════════════════════════════════════════════════
@@ -48,13 +57,19 @@ function readUint16BE(data: Uint8Array, offset: number): number {
48
57
  // ═══════════════════════════════════════════════════════════════════════════
49
58
 
50
59
  /**
51
- * Write an OQE file header.
60
+ * Write an OQE file header. Layout depends on `header.version`: v2 appends a
61
+ * second 12-byte IV (the content IV) after the metadata IV.
62
+ *
63
+ * The returned bytes are also used verbatim as the AES-GCM associated data for
64
+ * both sections (v2), so every field here — version, suite, flags, lengths and
65
+ * both IVs — is authenticated and cannot be tampered with undetected.
52
66
  *
53
67
  * @param header - Header data
54
- * @returns 30-byte header buffer
68
+ * @returns Header buffer (30 bytes for v1, 42 bytes for v2)
55
69
  */
56
70
  export function writeOQEHeader(header: OQEHeader): Uint8Array {
57
- const buffer = new Uint8Array(OQE_HEADER_SIZE);
71
+ const isV2 = header.version === OQE_FORMAT_VERSION_V2;
72
+ const buffer = new Uint8Array(oqeHeaderSize(header.version));
58
73
  let offset = 0;
59
74
 
60
75
  // Magic bytes (4 bytes)
@@ -79,23 +94,32 @@ export function writeOQEHeader(header: OQEHeader): Uint8Array {
79
94
  buffer.set(writeUint32BE(header.keyMaterialLength), offset);
80
95
  offset += 4;
81
96
 
82
- // IV (12 bytes)
97
+ // Metadata IV (12 bytes)
83
98
  buffer.set(header.iv, offset);
84
- // offset += 12;
99
+ offset += 12;
100
+
101
+ // Content IV (12 bytes, v2 only)
102
+ if (isV2) {
103
+ if (!header.contentIv || header.contentIv.length !== 12) {
104
+ throw new OQEError('INVALID_HEADER', 'v2 header requires a 12-byte contentIv');
105
+ }
106
+ buffer.set(header.contentIv, offset);
107
+ // offset += 12;
108
+ }
85
109
 
86
110
  return buffer;
87
111
  }
88
112
 
89
113
  /**
90
- * Parse an OQE file header.
114
+ * Parse an OQE file header (v1 or v2).
91
115
  *
92
- * @param data - File data (at least 30 bytes)
116
+ * @param data - File data
93
117
  * @returns Parsed header
94
118
  * @throws OQEError if header is invalid
95
119
  */
96
120
  export function parseOQEHeader(data: Uint8Array): OQEHeader {
97
- if (data.length < OQE_HEADER_SIZE) {
98
- throw new OQEError('INVALID_HEADER', `File too small: need ${OQE_HEADER_SIZE} bytes, got ${data.length}`);
121
+ if (data.length < OQE_HEADER_SIZE_V1) {
122
+ throw new OQEError('INVALID_HEADER', `File too small: need at least ${OQE_HEADER_SIZE_V1} bytes, got ${data.length}`);
99
123
  }
100
124
 
101
125
  let offset = 0;
@@ -109,7 +133,7 @@ export function parseOQEHeader(data: Uint8Array): OQEHeader {
109
133
 
110
134
  // Version
111
135
  const version = data[offset++];
112
- if (version !== OQE_FORMAT_VERSION) {
136
+ if (!SUPPORTED_OQE_VERSIONS.includes(version as typeof SUPPORTED_OQE_VERSIONS[number])) {
113
137
  throw new OQEError('UNSUPPORTED_VERSION', `Unsupported OQE version: ${version}`);
114
138
  }
115
139
 
@@ -117,12 +141,19 @@ export function parseOQEHeader(data: Uint8Array): OQEHeader {
117
141
  const algorithmSuiteRaw = data[offset++];
118
142
  if (
119
143
  algorithmSuiteRaw !== ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM &&
144
+ algorithmSuiteRaw !== ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM &&
120
145
  algorithmSuiteRaw !== ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM
121
146
  ) {
122
147
  throw new OQEError('UNSUPPORTED_ALGORITHM', `Unsupported algorithm suite: 0x${algorithmSuiteRaw.toString(16)}`);
123
148
  }
124
149
  const algorithmSuite = algorithmSuiteRaw as AlgorithmSuiteId;
125
150
 
151
+ const isV2 = version === OQE_FORMAT_VERSION_V2;
152
+ const headerSize = oqeHeaderSize(version);
153
+ if (data.length < headerSize) {
154
+ throw new OQEError('INVALID_HEADER', `Truncated v${version} header: need ${headerSize} bytes, got ${data.length}`);
155
+ }
156
+
126
157
  // Flags
127
158
  const flags = readUint32BE(data, offset);
128
159
  offset += 4;
@@ -135,8 +166,12 @@ export function parseOQEHeader(data: Uint8Array): OQEHeader {
135
166
  const keyMaterialLength = readUint32BE(data, offset);
136
167
  offset += 4;
137
168
 
138
- // IV
169
+ // Metadata IV
139
170
  const iv = data.slice(offset, offset + 12);
171
+ offset += 12;
172
+
173
+ // Content IV (v2 only)
174
+ const contentIv = isV2 ? data.slice(offset, offset + 12) : undefined;
140
175
 
141
176
  return {
142
177
  version,
@@ -145,6 +180,7 @@ export function parseOQEHeader(data: Uint8Array): OQEHeader {
145
180
  metadataLength,
146
181
  keyMaterialLength,
147
182
  iv,
183
+ contentIv,
148
184
  };
149
185
  }
150
186
 
@@ -247,6 +283,62 @@ export function parseHybridKeyMaterial(data: Uint8Array): HybridKeyMaterial {
247
283
  };
248
284
  }
249
285
 
286
+ /**
287
+ * Serialize v2 hybrid key material (single AND-combined wrap).
288
+ *
289
+ * Format:
290
+ * - X25519 ephemeral PK (32 bytes)
291
+ * - Kyber ciphertext length (2 bytes) + Kyber ciphertext (variable)
292
+ * - Content-key wrap nonce (24 bytes)
293
+ * - Wrapped content key length (2 bytes) + wrapped content key (variable)
294
+ */
295
+ export function serializeHybridKeyMaterialV2(km: HybridKeyMaterialV2): Uint8Array {
296
+ const parts: Uint8Array[] = [];
297
+
298
+ parts.push(km.x25519EphemeralPk);
299
+
300
+ parts.push(writeUint16BE(km.kyberCiphertext.length));
301
+ parts.push(km.kyberCiphertext);
302
+
303
+ parts.push(km.ckWrapNonce);
304
+
305
+ parts.push(writeUint16BE(km.ckWrapped.length));
306
+ parts.push(km.ckWrapped);
307
+
308
+ const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
309
+ const result = new Uint8Array(totalLength);
310
+ let offset = 0;
311
+ for (const part of parts) {
312
+ result.set(part, offset);
313
+ offset += part.length;
314
+ }
315
+ return result;
316
+ }
317
+
318
+ /**
319
+ * Parse v2 hybrid key material (single AND-combined wrap).
320
+ */
321
+ export function parseHybridKeyMaterialV2(data: Uint8Array): HybridKeyMaterialV2 {
322
+ let offset = 0;
323
+
324
+ const x25519EphemeralPk = data.slice(offset, offset + 32);
325
+ offset += 32;
326
+
327
+ const kyberCtLen = readUint16BE(data, offset);
328
+ offset += 2;
329
+ const kyberCiphertext = data.slice(offset, offset + kyberCtLen);
330
+ offset += kyberCtLen;
331
+
332
+ const ckWrapNonce = data.slice(offset, offset + 24);
333
+ offset += 24;
334
+
335
+ const ckWrappedLen = readUint16BE(data, offset);
336
+ offset += 2;
337
+ const ckWrapped = data.slice(offset, offset + ckWrappedLen);
338
+
339
+ return { x25519EphemeralPk, kyberCiphertext, ckWrapNonce, ckWrapped };
340
+ }
341
+
250
342
  /**
251
343
  * Serialize password mode key material.
252
344
  *
@@ -343,7 +435,7 @@ export function assembleOQEFile(components: OQEFileComponents): Uint8Array {
343
435
  export function parseOQEFile(data: Uint8Array): OQEFileComponents {
344
436
  const header = parseOQEHeader(data);
345
437
 
346
- let offset = OQE_HEADER_SIZE;
438
+ let offset = oqeHeaderSize(header.version);
347
439
 
348
440
  // Key material
349
441
  const keyMaterial = data.slice(offset, offset + header.keyMaterialLength);
@@ -415,8 +507,11 @@ export const OQE_MIME_TYPE = 'application/x-omnituum-encrypted';
415
507
  * Get a display-friendly algorithm name from suite ID.
416
508
  */
417
509
  export function getAlgorithmName(suiteId: AlgorithmSuiteId): string {
510
+ if (suiteId === ALGORITHM_SUITES.HYBRID_X25519_MLKEM1024_AES256GCM) {
511
+ return 'Hybrid (X25519 + ML-KEM-1024 + AES-256-GCM)';
512
+ }
418
513
  if (suiteId === ALGORITHM_SUITES.HYBRID_X25519_KYBER768_AES256GCM) {
419
- return 'Hybrid (X25519 + Kyber768 + AES-256-GCM)';
514
+ return 'Hybrid legacy (X25519 + Kyber + AES-256-GCM, either-key)';
420
515
  }
421
516
  if (suiteId === ALGORITHM_SUITES.PASSWORD_ARGON2ID_AES256GCM) {
422
517
  return 'Password (Argon2id + AES-256-GCM)';
package/src/fs/index.ts CHANGED
@@ -62,8 +62,11 @@ export {
62
62
  export {
63
63
  writeOQEHeader,
64
64
  parseOQEHeader,
65
+ oqeHeaderSize,
65
66
  serializeHybridKeyMaterial,
66
67
  parseHybridKeyMaterial,
68
+ serializeHybridKeyMaterialV2,
69
+ parseHybridKeyMaterialV2,
67
70
  serializePasswordKeyMaterial,
68
71
  parsePasswordKeyMaterial,
69
72
  serializeMetadata,
@@ -83,6 +86,7 @@ export {
83
86
  encryptFile,
84
87
  encryptFileForSelf,
85
88
  encryptFileWithPassword,
89
+ combinedFileKekV2,
86
90
  } from './encrypt';
87
91
 
88
92
  // Main decryption API
package/src/fs/types.ts CHANGED
@@ -14,18 +14,47 @@
14
14
  /** Magic bytes: "OQEF" (Omnituum Quantum Encrypted File) */
15
15
  export const OQE_MAGIC = new Uint8Array([0x4f, 0x51, 0x45, 0x46]);
16
16
 
17
- /** Current format version */
18
- export const OQE_FORMAT_VERSION = 1;
17
+ /**
18
+ * OQE format versions.
19
+ *
20
+ * v1 (LEGACY, read-only): a single AES-GCM IV was reused for both the
21
+ * metadata section and the content section under the same content key —
22
+ * catastrophic GCM nonce reuse — and hybrid mode wrapped the content key
23
+ * independently under X25519 and Kyber (either secret alone unwrapped it).
24
+ * Kept only so pre-existing files remain decryptable.
25
+ *
26
+ * v2 (CURRENT, write format): distinct random IVs per AES-GCM section, the
27
+ * serialized header bound as AEAD associated data, and hybrid mode wraps the
28
+ * content key once under an AND-combined KEK (HKDF(ss_mlkem || ss_x25519)
29
+ * with transcript binding) — both primitives must be broken to unwrap.
30
+ * See the 2026-07-06 fs security fix.
31
+ */
32
+ export const OQE_FORMAT_VERSION_V1 = 1;
33
+ export const OQE_FORMAT_VERSION_V2 = 2;
34
+
35
+ /** Current (write) format version. */
36
+ export const OQE_FORMAT_VERSION = OQE_FORMAT_VERSION_V2;
37
+
38
+ /** Format versions this library can read. */
39
+ export const SUPPORTED_OQE_VERSIONS = [OQE_FORMAT_VERSION_V1, OQE_FORMAT_VERSION_V2] as const;
19
40
 
20
41
  /** Supported encryption modes */
21
42
  export type OQEMode = 'hybrid' | 'password';
22
43
 
23
44
  /** Algorithm suite identifiers */
24
45
  export const ALGORITHM_SUITES = {
25
- /** Hybrid: X25519 ECDH + Kyber768 KEM + AES-256-GCM */
46
+ /**
47
+ * LEGACY (read-only): Hybrid X25519 + Kyber with independent per-primitive
48
+ * wraps (either secret unwraps). Only appears in v1 files. Never written.
49
+ */
26
50
  HYBRID_X25519_KYBER768_AES256GCM: 0x01,
27
51
  /** Password: Argon2id + AES-256-GCM */
28
52
  PASSWORD_ARGON2ID_AES256GCM: 0x02,
53
+ /**
54
+ * Hybrid X25519 + ML-KEM-1024 with a single AND-combined KEK wrap
55
+ * (HKDF(ss_mlkem || ss_x25519), transcript-bound). Written by v2.
56
+ */
57
+ HYBRID_X25519_MLKEM1024_AES256GCM: 0x03,
29
58
  } as const;
30
59
 
31
60
  export type AlgorithmSuiteId = typeof ALGORITHM_SUITES[keyof typeof ALGORITHM_SUITES];
@@ -117,12 +146,29 @@ export interface OQEHeader {
117
146
  metadataLength: number;
118
147
  /** Length of key material section */
119
148
  keyMaterialLength: number;
120
- /** AES-GCM initialization vector */
149
+ /**
150
+ * AES-GCM IV for the metadata section. In v1 this same IV was (incorrectly)
151
+ * also used for the content section; v2 uses `contentIv` for content.
152
+ */
121
153
  iv: Uint8Array;
154
+ /**
155
+ * AES-GCM IV for the content section (v2 only). Distinct from `iv` so the
156
+ * two sections never share a (key, nonce) pair. Undefined for v1 files.
157
+ */
158
+ contentIv?: Uint8Array;
122
159
  }
123
160
 
124
- /** Fixed header size in bytes (before variable-length sections) */
125
- export const OQE_HEADER_SIZE = 30;
161
+ /** Fixed v1 header size in bytes (before variable-length sections). */
162
+ export const OQE_HEADER_SIZE_V1 = 30;
163
+
164
+ /** Fixed v2 header size: v1 layout plus a 12-byte content IV. */
165
+ export const OQE_HEADER_SIZE_V2 = 42;
166
+
167
+ /**
168
+ * @deprecated Use OQE_HEADER_SIZE_V1 / OQE_HEADER_SIZE_V2. Retained as the v1
169
+ * size for source compatibility.
170
+ */
171
+ export const OQE_HEADER_SIZE = OQE_HEADER_SIZE_V1;
126
172
 
127
173
  // ═══════════════════════════════════════════════════════════════════════════
128
174
  // KEY MATERIAL (Mode-Specific)
@@ -152,6 +198,31 @@ export interface HybridKeyMaterial {
152
198
  kyberNonce: Uint8Array;
153
199
  }
154
200
 
201
+ /**
202
+ * v2 Hybrid Mode Key Material — single AND-combined wrap.
203
+ *
204
+ * The content key is wrapped exactly once under a KEK derived from BOTH shared
205
+ * secrets: HKDF(ss_mlkem || ss_x25519) with the ephemeral X25519 key and the
206
+ * Kyber ciphertext bound into the info string. Both the X25519 and ML-KEM
207
+ * exchanges must succeed to unwrap — there is no per-primitive fallback.
208
+ *
209
+ * Serialized layout:
210
+ * - X25519 ephemeral public key (32 bytes)
211
+ * - Kyber KEM ciphertext (2-byte length prefix + data)
212
+ * - Content-key wrap nonce (24 bytes)
213
+ * - Wrapped content key (2-byte length prefix + data, 48 bytes)
214
+ */
215
+ export interface HybridKeyMaterialV2 {
216
+ /** X25519 ephemeral public key */
217
+ x25519EphemeralPk: Uint8Array;
218
+ /** Kyber KEM ciphertext */
219
+ kyberCiphertext: Uint8Array;
220
+ /** Wrap nonce for the single content-key wrap (NaCl secretbox) */
221
+ ckWrapNonce: Uint8Array;
222
+ /** Content key wrapped under the AND-combined KEK */
223
+ ckWrapped: Uint8Array;
224
+ }
225
+
155
226
  /**
156
227
  * Password Mode Key Material:
157
228
  * - Argon2id salt (32 bytes)
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ export type {
51
51
  HybridPublicKeys,
52
52
  HybridSecretKeys,
53
53
  HybridEnvelope,
54
+ HybridEnvelopeV1,
55
+ HybridEnvelopeV2,
54
56
  } from './crypto/hybrid';
55
57
 
56
58
  // ═══════════════════════════════════════════════════════════════════════════
@@ -370,7 +372,9 @@ export {
370
372
  VAULT_ENCRYPTED_VERSION,
371
373
  VAULT_ENCRYPTED_VERSION_V2,
372
374
  ENVELOPE_VERSION,
375
+ ENVELOPE_VERSION_V2,
373
376
  ENVELOPE_SUITE,
377
+ ENVELOPE_SUITE_V2,
374
378
  ENVELOPE_AEAD,
375
379
  VAULT_KDF,
376
380
  VAULT_KDF_V2,
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * Omnituum PQC Shared - Integrity Verification
3
3
  *
4
- * SHA-256 based integrity checking for vault contents.
4
+ * SHA-256 checksum over a vault's public identity records.
5
+ *
6
+ * SCOPE: this is an UNKEYED integrity checksum — it detects accidental
7
+ * corruption or field mix-ups, not malicious tampering. An attacker who edits
8
+ * the decrypted vault can simply recompute this value. Tamper *resistance* for
9
+ * a stored vault comes from the AES-256-GCM authentication tag on the encrypted
10
+ * file (see vault/encrypt.ts), not from this hash. Do not treat a matching
11
+ * integrity hash as authentication.
5
12
  */
6
13
 
7
14
  import type { HybridIdentityRecord } from '../vault/types';
8
- import { toHex } from '../crypto/primitives';
15
+ import { sha256, toHex, textEncoder } from '../crypto/primitives';
9
16
 
10
17
  // ═══════════════════════════════════════════════════════════════════════════
11
18
  // INTEGRITY HASH
12
19
  // ═══════════════════════════════════════════════════════════════════════════
13
20
 
14
21
  /**
15
- * Compute SHA-256 integrity hash for a list of identities.
16
- * Uses only the public keys and metadata to create a deterministic hash.
22
+ * Compute a SHA-256 checksum for a list of identities.
23
+ * Uses only the public keys and metadata (never secret keys). Deterministic:
24
+ * the canonical objects carry a fixed key order, which JSON.stringify preserves.
17
25
  */
18
26
  export function computeIntegrityHash(identities: HybridIdentityRecord[]): string {
19
- // Create a deterministic representation (exclude secret keys from hash input)
20
27
  const canonical = identities.map(i => ({
21
28
  id: i.id,
22
29
  name: i.name,
@@ -26,22 +33,16 @@ export function computeIntegrityHash(identities: HybridIdentityRecord[]): string
26
33
  rotationCount: i.rotationCount,
27
34
  }));
28
35
 
29
- const serialized = JSON.stringify(canonical, Object.keys(canonical[0] || {}).sort());
36
+ const serialized = JSON.stringify(canonical);
30
37
  return computeStringHash(serialized);
31
38
  }
32
39
 
33
40
  /**
34
- * Compute SHA-256 hash of a string (synchronous fallback).
41
+ * Real SHA-256 of a UTF-8 string, returned as lowercase hex. Synchronous via
42
+ * @noble/hashes (no Web Crypto async dependency).
35
43
  */
36
44
  function computeStringHash(str: string): string {
37
- // Simple hash for sync operation - actual implementation uses Web Crypto
38
- let hash = 0;
39
- for (let i = 0; i < str.length; i++) {
40
- const char = str.charCodeAt(i);
41
- hash = ((hash << 5) - hash) + char;
42
- hash = hash & hash;
43
- }
44
- return Math.abs(hash).toString(16).padStart(16, '0');
45
+ return toHex(sha256(textEncoder.encode(str)));
45
46
  }
46
47
 
47
48
  /**
@@ -55,7 +55,7 @@ export async function createIdentity(name: string): Promise<HybridIdentityRecord
55
55
  // Generate Kyber keypair
56
56
  const kyber = await generateKyberKeypair();
57
57
  if (!kyber) {
58
- console.error('Kyber key generation failed');
58
+ // Backend unavailable; caller handles the null return.
59
59
  return null;
60
60
  }
61
61
 
package/src/version.ts CHANGED
@@ -18,9 +18,16 @@ import {
18
18
  // VERSION CONSTANTS (FROZEN)
19
19
  // ═══════════════════════════════════════════════════════════════════════════
20
20
 
21
- /** HybridEnvelope version - hybrid encryption format (from registry) */
21
+ /** HybridEnvelope v1 - independent X25519/Kyber wraps (read-only legacy; see v2) */
22
22
  export const ENVELOPE_VERSION = OMNI_VERSIONS.HYBRID_V1;
23
23
 
24
+ /**
25
+ * HybridEnvelope v2 - AND-combined KEK (single wrap under
26
+ * HKDF(ss_mlkem || ss_x25519) with transcript binding). The version new
27
+ * envelopes are written in.
28
+ */
29
+ export const ENVELOPE_VERSION_V2 = OMNI_VERSIONS.HYBRID_V2;
30
+
24
31
  /** Legacy envelope version for backwards compatibility (from registry) */
25
32
  export const ENVELOPE_VERSION_LEGACY = DEPRECATED_VERSIONS.PQC_DEMO_HYBRID_V1;
26
33
 
@@ -33,9 +40,12 @@ export const VAULT_ENCRYPTED_VERSION = 'omnituum.vault.enc.v1' as const;
33
40
  /** EncryptedVaultFile v2 - encrypted vault format (Argon2id) */
34
41
  export const VAULT_ENCRYPTED_VERSION_V2 = 'omnituum.vault.enc.v2' as const;
35
42
 
36
- /** Algorithm suite for hybrid encryption */
43
+ /** Algorithm suite for hybrid encryption (v1 label — kept for wire compat) */
37
44
  export const ENVELOPE_SUITE = 'x25519+kyber768' as const;
38
45
 
46
+ /** Algorithm suite for hybrid v2 (matches the actual ML-KEM-1024 backend) */
47
+ export const ENVELOPE_SUITE_V2 = 'x25519+mlkem1024' as const;
48
+
39
49
  /** AEAD algorithm for envelope content */
40
50
  export const ENVELOPE_AEAD = 'xsalsa20poly1305' as const;
41
51
 
@@ -55,6 +65,7 @@ export const VAULT_ALGORITHM = 'AES-256-GCM' as const;
55
65
  /** Envelope versions we can read */
56
66
  export const SUPPORTED_ENVELOPE_VERSIONS = [
57
67
  ENVELOPE_VERSION,
68
+ ENVELOPE_VERSION_V2,
58
69
  ENVELOPE_VERSION_LEGACY,
59
70
  ] as const;
60
71
 
@@ -147,8 +158,16 @@ export function isVaultEncryptedVersionSupported(version: string): boolean {
147
158
  // ENVELOPE VALIDATION
148
159
  // ═══════════════════════════════════════════════════════════════════════════
149
160
 
161
+ /** v1-shaped required fields: independent per-primitive wraps. */
162
+ const ENVELOPE_V1_FIELDS = ['x25519Epk', 'x25519Wrap', 'kyberKemCt', 'kyberWrap', 'contentNonce', 'ciphertext', 'meta'];
163
+
164
+ /** v2-shaped required fields: single wrap under the AND-combined KEK. */
165
+ const ENVELOPE_V2_FIELDS = ['x25519Epk', 'kyberKemCt', 'ckWrap', 'contentNonce', 'ciphertext', 'meta'];
166
+
150
167
  /**
151
- * Validate envelope structure and version.
168
+ * Validate envelope structure and version. Dispatches per-version since v1
169
+ * and v2 have different wire shapes (independent dual wraps vs. a single
170
+ * AND-combined wrap) — see hybrid.ts for the security rationale.
152
171
  * Returns detailed validation result.
153
172
  */
154
173
  export function validateEnvelope(envelope: unknown): {
@@ -171,9 +190,13 @@ export function validateEnvelope(envelope: unknown): {
171
190
  errors.push(`Unsupported envelope version: ${env.v}`);
172
191
  }
173
192
 
193
+ const isV2 = env.v === ENVELOPE_VERSION_V2;
194
+ const expectedSuite = isV2 ? ENVELOPE_SUITE_V2 : ENVELOPE_SUITE;
195
+ const requiredFields = isV2 ? ENVELOPE_V2_FIELDS : ENVELOPE_V1_FIELDS;
196
+
174
197
  // Suite check
175
- if (env.suite !== ENVELOPE_SUITE) {
176
- errors.push(`Invalid suite: expected "${ENVELOPE_SUITE}", got "${env.suite}"`);
198
+ if (env.suite !== expectedSuite) {
199
+ errors.push(`Invalid suite: expected "${expectedSuite}", got "${env.suite}"`);
177
200
  }
178
201
 
179
202
  // AEAD check
@@ -182,8 +205,7 @@ export function validateEnvelope(envelope: unknown): {
182
205
  }
183
206
 
184
207
  // Required fields
185
- const required = ['x25519Epk', 'x25519Wrap', 'kyberKemCt', 'kyberWrap', 'contentNonce', 'ciphertext', 'meta'];
186
- for (const field of required) {
208
+ for (const field of requiredFields) {
187
209
  if (!(field in env)) {
188
210
  errors.push(`Missing required field: ${field}`);
189
211
  }