@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/crypto/hybrid.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Post-quantum secure hybrid encryption combining:
|
|
5
5
|
* - X25519 ECDH (classical, proven security)
|
|
6
|
-
* -
|
|
6
|
+
* - ML-KEM-1024 (post-quantum, FIPS 203, NIST Level 5)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* v2 (current write format): the content key is wrapped once, under a KEK
|
|
9
|
+
* derived from BOTH shared secrets (HKDF(ss_mlkem || ss_x25519) with
|
|
10
|
+
* transcript binding) — both key exchanges must succeed to decrypt, so
|
|
11
|
+
* breaking either primitive alone is insufficient.
|
|
12
|
+
*
|
|
13
|
+
* v1 (read-only legacy): wrapped the key independently under each
|
|
14
|
+
* primitive; either secret alone sufficed. Kept only for decrypting
|
|
15
|
+
* existing envelopes — see the 2026-07-05 security audit.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
import nacl from 'tweetnacl';
|
|
@@ -30,8 +36,8 @@ import {
|
|
|
30
36
|
isKyberAvailable,
|
|
31
37
|
} from './kyber';
|
|
32
38
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
ENVELOPE_VERSION_V2,
|
|
40
|
+
ENVELOPE_SUITE_V2,
|
|
35
41
|
ENVELOPE_AEAD,
|
|
36
42
|
assertEnvelopeVersion,
|
|
37
43
|
} from '../version';
|
|
@@ -76,22 +82,45 @@ export interface HybridSecretKeys {
|
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
|
-
*
|
|
85
|
+
* HybridEnvelopeV1 -- OmniHybridV1 from the registry with
|
|
80
86
|
* app-semantic meta fields (senderName, senderId).
|
|
81
87
|
* The Omni registry type defines only the crypto-relevant surface.
|
|
82
88
|
*
|
|
83
89
|
* Intentionally a type alias (not interface extends) to discourage
|
|
84
90
|
* further field additions here. New app-semantic fields belong in
|
|
85
91
|
* product-level types, not in the shared crypto layer.
|
|
92
|
+
*
|
|
93
|
+
* READ-ONLY LEGACY: v1 wraps the content key independently under X25519
|
|
94
|
+
* and Kyber, so either secret alone unwraps it — min(X25519, ML-KEM)
|
|
95
|
+
* security. hybridEncrypt no longer produces this shape; hybridDecrypt
|
|
96
|
+
* still reads it.
|
|
86
97
|
*/
|
|
87
98
|
import type { OmniHybridV1 } from '@omnituum/envelope-registry';
|
|
88
|
-
export type
|
|
99
|
+
export type HybridEnvelopeV1 = Omit<OmniHybridV1, 'meta'> & {
|
|
89
100
|
meta: OmniHybridV1['meta'] & {
|
|
90
101
|
senderName?: string;
|
|
91
102
|
senderId?: string;
|
|
92
103
|
};
|
|
93
104
|
};
|
|
94
105
|
|
|
106
|
+
/**
|
|
107
|
+
* HybridEnvelopeV2 -- OmniHybridV2 from the registry with app-semantic meta
|
|
108
|
+
* fields (senderName, senderId), same pattern as HybridEnvelopeV1. Single
|
|
109
|
+
* wrap of the content key under an AND-combined KEK:
|
|
110
|
+
* HKDF-SHA256(ss_mlkem || ss_x25519) with the envelope's own KEM values
|
|
111
|
+
* bound into the info string. Both primitives must be broken to unwrap.
|
|
112
|
+
*/
|
|
113
|
+
import type { OmniHybridV2 } from '@omnituum/envelope-registry';
|
|
114
|
+
export type HybridEnvelopeV2 = Omit<OmniHybridV2, 'meta'> & {
|
|
115
|
+
meta: OmniHybridV2['meta'] & {
|
|
116
|
+
senderName?: string;
|
|
117
|
+
senderId?: string;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Any hybrid envelope this module can decrypt. */
|
|
122
|
+
export type HybridEnvelope = HybridEnvelopeV1 | HybridEnvelopeV2;
|
|
123
|
+
|
|
95
124
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
125
|
// HELPERS
|
|
97
126
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -119,7 +148,7 @@ export async function generateHybridIdentity(name: string): Promise<HybridIdenti
|
|
|
119
148
|
// Generate Kyber keypair
|
|
120
149
|
const kyber = await generateKyberKeypair();
|
|
121
150
|
if (!kyber) {
|
|
122
|
-
|
|
151
|
+
// Backend unavailable; caller handles the null return.
|
|
123
152
|
return null;
|
|
124
153
|
}
|
|
125
154
|
|
|
@@ -184,7 +213,34 @@ export function getSecretKeys(identity: HybridIdentity): HybridSecretKeys {
|
|
|
184
213
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
185
214
|
|
|
186
215
|
/**
|
|
187
|
-
*
|
|
216
|
+
* Derive the v2 AND-combined KEK. Requires BOTH shared secrets; the
|
|
217
|
+
* envelope's own KEM values are bound into the info string so a spliced
|
|
218
|
+
* epk/kemCt from another envelope derives a different KEK and the wrap
|
|
219
|
+
* fails authentication (X-Wing-style transcript binding).
|
|
220
|
+
*/
|
|
221
|
+
function combinedKekV2(
|
|
222
|
+
kyberShared: Uint8Array,
|
|
223
|
+
x25519Shared: Uint8Array,
|
|
224
|
+
x25519EpkHex: string,
|
|
225
|
+
kyberKemCtB64: string
|
|
226
|
+
): Uint8Array {
|
|
227
|
+
const ikm = new Uint8Array(kyberShared.length + x25519Shared.length);
|
|
228
|
+
ikm.set(kyberShared, 0);
|
|
229
|
+
ikm.set(x25519Shared, kyberShared.length);
|
|
230
|
+
try {
|
|
231
|
+
return hkdfFlex(ikm, 'omnituum/hybrid-v2', `wrap-ck|${x25519EpkHex}|${kyberKemCtB64}`);
|
|
232
|
+
} finally {
|
|
233
|
+
ikm.fill(0);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Encrypt a message using hybrid X25519 + ML-KEM-1024 encryption (v2).
|
|
239
|
+
*
|
|
240
|
+
* The content key is wrapped exactly once, under a KEK derived from both
|
|
241
|
+
* shared secrets together — breaking either primitive alone is insufficient
|
|
242
|
+
* to unwrap. (v1 wrapped the key independently under each primitive, which
|
|
243
|
+
* reduced security to min(X25519, ML-KEM); v1 is now read-only legacy.)
|
|
188
244
|
*
|
|
189
245
|
* @param plaintext - Message to encrypt (string or bytes)
|
|
190
246
|
* @param recipientPublicKeys - Recipient's public keys
|
|
@@ -194,83 +250,104 @@ export async function hybridEncrypt(
|
|
|
194
250
|
plaintext: string | Uint8Array,
|
|
195
251
|
recipientPublicKeys: HybridPublicKeys,
|
|
196
252
|
sender?: { name?: string; id?: string }
|
|
197
|
-
): Promise<
|
|
253
|
+
): Promise<HybridEnvelopeV2> {
|
|
198
254
|
const pt = typeof plaintext === 'string' ? textEncoder.encode(plaintext) : plaintext;
|
|
199
255
|
|
|
200
256
|
// 1. Generate random content key (32 bytes)
|
|
201
257
|
const CK = rand32();
|
|
202
258
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
259
|
+
try {
|
|
260
|
+
// 2. Encrypt content with content key
|
|
261
|
+
const contentNonce = rand24();
|
|
262
|
+
const ciphertext = nacl.secretbox(pt, contentNonce, CK);
|
|
263
|
+
|
|
264
|
+
// 3. X25519 ECDH shared secret (ephemeral)
|
|
265
|
+
const x25519EphKp = nacl.box.keyPair();
|
|
266
|
+
const recipientX25519Pk = fromHex(recipientPublicKeys.x25519PubHex);
|
|
267
|
+
const x25519Shared = nacl.scalarMult(x25519EphKp.secretKey, recipientX25519Pk);
|
|
268
|
+
const x25519EpkHex = toHex(x25519EphKp.publicKey);
|
|
269
|
+
|
|
270
|
+
// 4. ML-KEM-1024 shared secret
|
|
271
|
+
const kyberResult = await kyberEncapsulate(recipientPublicKeys.kyberPubB64);
|
|
272
|
+
const kyberKemCtB64 = b64(kyberResult.ciphertext);
|
|
273
|
+
|
|
274
|
+
// 5. Single wrap under the AND-combined KEK
|
|
275
|
+
const kek = combinedKekV2(kyberResult.sharedSecret, x25519Shared, x25519EpkHex, kyberKemCtB64);
|
|
276
|
+
const ckWrapNonce = rand24();
|
|
277
|
+
const ckWrapped = nacl.secretbox(CK, ckWrapNonce, kek);
|
|
278
|
+
kek.fill(0);
|
|
279
|
+
x25519Shared.fill(0);
|
|
280
|
+
kyberResult.sharedSecret.fill(0);
|
|
281
|
+
x25519EphKp.secretKey.fill(0);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
v: ENVELOPE_VERSION_V2,
|
|
285
|
+
suite: ENVELOPE_SUITE_V2,
|
|
286
|
+
aead: ENVELOPE_AEAD,
|
|
287
|
+
x25519Epk: x25519EpkHex,
|
|
288
|
+
kyberKemCt: kyberKemCtB64,
|
|
289
|
+
ckWrap: {
|
|
290
|
+
nonce: b64(ckWrapNonce),
|
|
291
|
+
wrapped: b64(ckWrapped),
|
|
292
|
+
},
|
|
293
|
+
contentNonce: b64(contentNonce),
|
|
294
|
+
ciphertext: b64(ciphertext),
|
|
295
|
+
meta: {
|
|
296
|
+
createdAt: new Date().toISOString(),
|
|
297
|
+
senderName: sender?.name,
|
|
298
|
+
senderId: sender?.id,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
} finally {
|
|
302
|
+
CK.fill(0);
|
|
303
|
+
}
|
|
244
304
|
}
|
|
245
305
|
|
|
246
306
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
247
307
|
// DECRYPTION
|
|
248
308
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
249
309
|
|
|
310
|
+
/** Unwrap the content key from a v2 envelope — requires BOTH secrets. */
|
|
311
|
+
async function unwrapCkV2(
|
|
312
|
+
envelope: HybridEnvelopeV2,
|
|
313
|
+
secretKeys: HybridSecretKeys
|
|
314
|
+
): Promise<Uint8Array> {
|
|
315
|
+
// ML-KEM decapsulation (post-quantum half). Failure here is fatal — there
|
|
316
|
+
// is deliberately no classical fallback path in v2.
|
|
317
|
+
const kyberShared = await kyberDecapsulate(envelope.kyberKemCt, secretKeys.kyberSecB64);
|
|
318
|
+
|
|
319
|
+
// X25519 ECDH (classical half)
|
|
320
|
+
const ephPk = fromHex(envelope.x25519Epk);
|
|
321
|
+
const sk = fromHex(secretKeys.x25519SecHex);
|
|
322
|
+
const x25519Shared = nacl.scalarMult(sk, ephPk);
|
|
323
|
+
|
|
324
|
+
const kek = combinedKekV2(kyberShared, x25519Shared, envelope.x25519Epk, envelope.kyberKemCt);
|
|
325
|
+
kyberShared.fill(0);
|
|
326
|
+
x25519Shared.fill(0);
|
|
327
|
+
|
|
328
|
+
const CK = nacl.secretbox.open(ub64(envelope.ckWrap.wrapped), ub64(envelope.ckWrap.nonce), kek);
|
|
329
|
+
kek.fill(0);
|
|
330
|
+
if (!CK) {
|
|
331
|
+
throw new Error('Could not unwrap content key — combined-KEK authentication failed');
|
|
332
|
+
}
|
|
333
|
+
return CK;
|
|
334
|
+
}
|
|
335
|
+
|
|
250
336
|
/**
|
|
251
|
-
*
|
|
337
|
+
* Unwrap the content key from a v1 (or pqc-demo) envelope.
|
|
252
338
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* @param secretKeys - Recipient's secret keys
|
|
258
|
-
* @returns Decrypted plaintext as bytes
|
|
339
|
+
* LEGACY READ PATH: v1 wrapped the key independently under each primitive,
|
|
340
|
+
* so either secret alone suffices — this is exactly the defect v2 fixes.
|
|
341
|
+
* Kept only so envelopes written before v2 remain readable; re-encrypt to
|
|
342
|
+
* v2 where the post-quantum guarantee matters.
|
|
259
343
|
*/
|
|
260
|
-
|
|
261
|
-
envelope:
|
|
262
|
-
secretKeys: HybridSecretKeys
|
|
344
|
+
async function unwrapCkV1(
|
|
345
|
+
envelope: HybridEnvelopeV1,
|
|
346
|
+
secretKeys: HybridSecretKeys,
|
|
347
|
+
saltPrefix: string
|
|
263
348
|
): Promise<Uint8Array> {
|
|
264
|
-
// Validate envelope version (throws VersionMismatchError if unsupported)
|
|
265
|
-
const version = envelope.v as string;
|
|
266
|
-
assertEnvelopeVersion(version);
|
|
267
|
-
|
|
268
349
|
let CK: Uint8Array | null = null;
|
|
269
350
|
|
|
270
|
-
// Determine HKDF salt based on envelope version
|
|
271
|
-
const saltPrefix = version === 'pqc-demo.hybrid.v1' ? 'pqc-demo' : 'omnituum';
|
|
272
|
-
|
|
273
|
-
// Try Kyber decapsulation first (post-quantum)
|
|
274
351
|
try {
|
|
275
352
|
const kyberShared = await kyberDecapsulate(envelope.kyberKemCt, secretKeys.kyberSecB64);
|
|
276
353
|
const kyberKek = hkdfFlex(kyberShared, `${saltPrefix}/kyber`, 'wrap-ck');
|
|
@@ -279,14 +356,10 @@ export async function hybridDecrypt(
|
|
|
279
356
|
ub64(envelope.kyberWrap.nonce),
|
|
280
357
|
kyberKek
|
|
281
358
|
);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
} catch (e) {
|
|
286
|
-
console.warn('[Hybrid] Kyber decapsulation failed:', e);
|
|
359
|
+
} catch {
|
|
360
|
+
// Fall through to the X25519 wrap.
|
|
287
361
|
}
|
|
288
362
|
|
|
289
|
-
// Try X25519 if Kyber failed
|
|
290
363
|
if (!CK) {
|
|
291
364
|
try {
|
|
292
365
|
const ephPk = fromHex(envelope.x25519Epk);
|
|
@@ -298,17 +371,45 @@ export async function hybridDecrypt(
|
|
|
298
371
|
ub64(envelope.x25519Wrap.nonce),
|
|
299
372
|
x25519Kek
|
|
300
373
|
);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
} catch (e) {
|
|
305
|
-
console.warn('[Hybrid] X25519 decryption failed:', e);
|
|
374
|
+
} catch {
|
|
375
|
+
// Handled below.
|
|
306
376
|
}
|
|
307
377
|
}
|
|
308
378
|
|
|
309
379
|
if (!CK) {
|
|
310
380
|
throw new Error('Could not unwrap content key with either algorithm');
|
|
311
381
|
}
|
|
382
|
+
return CK;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Decrypt a hybrid envelope (v2, v1, or legacy pqc-demo).
|
|
387
|
+
*
|
|
388
|
+
* v2: the content key is unwrapped from a single AND-combined KEK — both
|
|
389
|
+
* ML-KEM and X25519 secrets are required, and any failure is fatal.
|
|
390
|
+
* v1/legacy: read-only compatibility path (either secret suffices — the
|
|
391
|
+
* defect v2 exists to fix).
|
|
392
|
+
*
|
|
393
|
+
* @param envelope - Encrypted envelope
|
|
394
|
+
* @param secretKeys - Recipient's secret keys
|
|
395
|
+
* @returns Decrypted plaintext as bytes
|
|
396
|
+
*/
|
|
397
|
+
export async function hybridDecrypt(
|
|
398
|
+
envelope: HybridEnvelope,
|
|
399
|
+
secretKeys: HybridSecretKeys
|
|
400
|
+
): Promise<Uint8Array> {
|
|
401
|
+
// Validate envelope version (throws VersionMismatchError if unsupported)
|
|
402
|
+
const version = envelope.v as string;
|
|
403
|
+
assertEnvelopeVersion(version);
|
|
404
|
+
|
|
405
|
+
const CK =
|
|
406
|
+
version === ENVELOPE_VERSION_V2
|
|
407
|
+
? await unwrapCkV2(envelope as HybridEnvelopeV2, secretKeys)
|
|
408
|
+
: await unwrapCkV1(
|
|
409
|
+
envelope as HybridEnvelopeV1,
|
|
410
|
+
secretKeys,
|
|
411
|
+
version === 'pqc-demo.hybrid.v1' ? 'pqc-demo' : 'omnituum'
|
|
412
|
+
);
|
|
312
413
|
|
|
313
414
|
// Decrypt content
|
|
314
415
|
const pt = nacl.secretbox.open(
|
|
@@ -316,6 +417,7 @@ export async function hybridDecrypt(
|
|
|
316
417
|
ub64(envelope.contentNonce),
|
|
317
418
|
CK
|
|
318
419
|
);
|
|
420
|
+
CK.fill(0);
|
|
319
421
|
|
|
320
422
|
if (!pt) {
|
|
321
423
|
throw new Error('Content authentication failed');
|