@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.
- package/dist/crypto/index.cjs +91 -54
- package/dist/crypto/index.d.cts +43 -11
- package/dist/crypto/index.d.ts +43 -11
- package/dist/crypto/index.js +87 -55
- package/dist/{decrypt-eSHlbh1j.d.cts → decrypt-O1iqFIDL.d.cts} +80 -6
- package/dist/{decrypt-eSHlbh1j.d.ts → decrypt-O1iqFIDL.d.ts} +80 -6
- package/dist/fs/index.cjs +286 -147
- package/dist/fs/index.d.cts +28 -7
- package/dist/fs/index.d.ts +28 -7
- package/dist/fs/index.js +277 -147
- package/dist/index.cjs +368 -229
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +362 -230
- package/dist/{integrity-ByMp24VA.d.cts → integrity-BkBDrHA-.d.cts} +12 -4
- package/dist/{integrity-BPvNsC50.d.ts → integrity-DsFJv5c-.d.ts} +12 -4
- package/dist/{types-Dx_AFqow.d.cts → types-DmnVueAY.d.cts} +15 -5
- package/dist/{types-Dx_AFqow.d.ts → types-DmnVueAY.d.ts} +15 -5
- package/dist/utils/index.cjs +7 -10
- package/dist/utils/index.d.cts +2 -2
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +7 -10
- package/dist/vault/index.cjs +8 -11
- package/dist/vault/index.d.cts +2 -2
- package/dist/vault/index.d.ts +2 -2
- package/dist/vault/index.js +8 -11
- package/package.json +19 -25
- package/src/crypto/dilithium.ts +4 -4
- package/src/crypto/hybrid.ts +182 -80
- package/src/crypto/index.ts +8 -1
- package/src/crypto/kyber.ts +20 -0
- package/src/fs/decrypt.ts +145 -68
- package/src/fs/encrypt.ts +161 -112
- package/src/fs/format.ts +110 -15
- package/src/fs/index.ts +4 -0
- package/src/fs/types.ts +77 -6
- package/src/index.ts +9 -0
- package/src/utils/integrity.ts +16 -15
- package/src/vault/manager.ts +1 -1
- 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
|
-
|
|
12
|
-
|
|
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
|
|
68
|
+
* @returns Header buffer (30 bytes for v1, 42 bytes for v2)
|
|
55
69
|
*/
|
|
56
70
|
export function writeOQEHeader(header: OQEHeader): Uint8Array {
|
|
57
|
-
const
|
|
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
|
-
|
|
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
|
|
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 <
|
|
98
|
-
throw new OQEError('INVALID_HEADER', `File too small: need ${
|
|
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
|
|
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 =
|
|
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 +
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -69,6 +71,11 @@ export {
|
|
|
69
71
|
kyberWrapKey,
|
|
70
72
|
kyberUnwrapKey,
|
|
71
73
|
KYBER_SUITE,
|
|
74
|
+
KYBER_PUBLIC_KEY_SIZE,
|
|
75
|
+
KYBER_SECRET_KEY_SIZE,
|
|
76
|
+
KYBER_CIPHERTEXT_SIZE,
|
|
77
|
+
KYBER_SHARED_SECRET_SIZE,
|
|
78
|
+
KYBER_SEED_SIZE,
|
|
72
79
|
} from './crypto/kyber';
|
|
73
80
|
|
|
74
81
|
export type {
|
|
@@ -365,7 +372,9 @@ export {
|
|
|
365
372
|
VAULT_ENCRYPTED_VERSION,
|
|
366
373
|
VAULT_ENCRYPTED_VERSION_V2,
|
|
367
374
|
ENVELOPE_VERSION,
|
|
375
|
+
ENVELOPE_VERSION_V2,
|
|
368
376
|
ENVELOPE_SUITE,
|
|
377
|
+
ENVELOPE_SUITE_V2,
|
|
369
378
|
ENVELOPE_AEAD,
|
|
370
379
|
VAULT_KDF,
|
|
371
380
|
VAULT_KDF_V2,
|
package/src/utils/integrity.ts
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Omnituum PQC Shared - Integrity Verification
|
|
3
3
|
*
|
|
4
|
-
* SHA-256
|
|
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
|
|
16
|
-
* Uses only the public keys and metadata
|
|
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
|
|
36
|
+
const serialized = JSON.stringify(canonical);
|
|
30
37
|
return computeStringHash(serialized);
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
/**
|
package/src/vault/manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 !==
|
|
176
|
-
errors.push(`Invalid suite: expected "${
|
|
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
|
|
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
|
}
|