@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.
- package/dist/crypto/index.cjs +81 -54
- package/dist/crypto/index.d.cts +33 -11
- package/dist/crypto/index.d.ts +33 -11
- package/dist/crypto/index.js +81 -54
- 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 +358 -229
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +357 -230
- package/dist/{integrity-BenoFsmP.d.cts → integrity-BkBDrHA-.d.cts} +12 -4
- package/dist/{integrity-D9J98Bty.d.ts → integrity-DsFJv5c-.d.ts} +12 -4
- package/dist/{types-CRka8PC7.d.cts → types-DmnVueAY.d.cts} +14 -4
- package/dist/{types-CRka8PC7.d.ts → types-DmnVueAY.d.ts} +14 -4
- 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 +7 -7
- package/src/crypto/dilithium.ts +4 -4
- package/src/crypto/hybrid.ts +182 -80
- package/src/crypto/index.ts +2 -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 +4 -0
- package/src/utils/integrity.ts +16 -15
- package/src/vault/manager.ts +1 -1
- 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
|
|
49
|
-
*
|
|
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
|
-
|
|
56
|
-
const { header
|
|
74
|
+
const parsed = parseOQEFile(encryptedData);
|
|
75
|
+
const { header } = parsed;
|
|
57
76
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,
|
|
159
|
+
const x25519Shared = nacl.scalarMult(sk, km.x25519EphemeralPk);
|
|
97
160
|
const x25519Kek = hkdfFlex(x25519Shared, 'omnituum/fs/x25519', 'wrap-content-key');
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
117
|
-
metadata =
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
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 +
|
|
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
|
-
|
|
22
|
+
OQE_FORMAT_VERSION_V2,
|
|
22
23
|
ALGORITHM_SUITES,
|
|
23
24
|
OQEMetadata,
|
|
24
25
|
OQEHeader,
|
|
25
|
-
|
|
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
|
-
|
|
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 +
|
|
65
|
-
*
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
header
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
salt
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|