Skip to content

Commit 40a1301

Browse files
authored
fix: enhance decapsulation to prevent oracle-style leakage and improve error handling for malformed ciphertexts (#3)
1 parent 57a41c1 commit 40a1301

9 files changed

Lines changed: 324 additions & 35 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ binding = H(SLSS_pk || TDD_pk || EGRW_pk)
155155

156156
All three shares are required to recover the secret, and the binding ensures the three problem instances are cryptographically linked.
157157

158+
> ⚠️ Implementation note: the library prefers native SHAKE256 (XOF) support. If the runtime lacks native SHAKE256, kMOSAIC falls back to a counter-mode SHA3-256 based construction which may not provide the same security margins as a native XOF. For production deployments, ensure your runtime supports SHAKE256 or use an environment that provides it.
159+
158160
### Hard Problems
159161

160162
#### SLSS (Sparse Lattice Subset Sum)

SECURITY_REPORT.md

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,27 @@ while (idx < n) {
181181

182182
This eliminates statistical bias by rejecting values that would cause modular reduction bias.
183183

184+
### VULN-014: Decapsulation throws on malformed ciphertext (implicit oracle)
185+
186+
**File:** `src/kem/index.ts`
187+
**Lines:** 360-420 (approx)
188+
**Status:****FIXED**
189+
190+
#### Description
191+
192+
Certain malformed or corrupted ciphertexts (for example, a truncated NIZK proof or malformed fragment lengths) could cause `decapsulate()` to throw exceptions or exhibit distinguishable behavior. This could be used as a decryption oracle by an attacker to learn about ciphertext validity.
193+
194+
#### Fix Applied
195+
196+
- Compute the **implicit rejection value** early from the raw ciphertext bytes and use it as the default return value on any validation failure.
197+
- Wrap critical parsing and verification steps in try/catch blocks: serialization, component decryption (SLSS/TDD/EGRW), NIZK deserialization and verification, and re-encapsulation. Any failure marks decapsulation as invalid but does not throw.
198+
- Normalize share lengths (expect 32-byte shares) and use zeroed fallbacks to avoid reconstruction exceptions.
199+
- Replace direct ciphertext byte comparison with fixed-length SHA3-256 hash comparisons to avoid leaks from variable-length ciphertexts.
200+
- Add a public key consistency check: `sha3_256(serializePublicKey(publicKey)) === secretKey.publicKeyHash`; treat mismatches as invalid decapsulation.
201+
- Added unit tests exercising tampering and malformed inputs: `test/kem-malformed.test.ts`.
202+
203+
These changes ensure `decapsulate()` always returns a 32-byte pseudorandom secret (implicit reject) on invalid input, preventing oracle-style leakage.
204+
184205
---
185206

186207
### VULN-005: Potential Integer Precision Issues
@@ -257,17 +278,21 @@ JavaScript's garbage collector may copy buffer contents during compaction. The `
257278

258279
**File:** `src/utils/shake.ts`
259280
**Lines:** 82-100
260-
**Status:** 🟡 ACKNOWLEDGED
281+
**Status:** ✅ MITIGATED
261282

262283
#### Description
263284

264285
The counter-mode SHA3-256 fallback is not a proven XOF construction. While unlikely to be used on Node.js/Bun, security properties are unverified.
265286

266-
#### Mitigation
287+
#### Mitigation / Fix Applied
267288

268-
- Native SHAKE256 is available in all target environments (Node.js 18+, Bun)
269-
- Fallback only triggers in edge cases
270-
- Consider adding warning log when fallback is used
289+
- Added `isNativeShake256Available()` helper to allow application code to detect and enforce native SHAKE256 availability.
290+
- Added an explicit README note advising production deployments to use native SHAKE256 or a runtime that supports it.
291+
- Fallback continues to exist for compatibility, but the above mitigations reduce the risk and make it visible to operators.
292+
293+
#### Recommendation
294+
295+
For highest assurance, consider adding a configuration flag that causes startup to fail when native SHAKE256 is unavailable.
271296

272297
---
273298

@@ -370,6 +395,7 @@ Generator cache creates timing differences between cache hits and misses, potent
370395
| VULN-001 | TDD plaintext storage | ✅ FIXED | XOR encryption with masked-matrix keystream |
371396
| VULN-002 | EGRW randomness leak | ✅ FIXED | Ephemeral walk vertex derivation |
372397
| VULN-004 | Modular bias | ✅ FIXED | Rejection sampling in TDD |
398+
| VULN-014 | Decapsulation oracle | ✅ FIXED | Safe parsing, implicit-reject, hash-compare |
373399

374400
### Acknowledged Limitations
375401

@@ -397,9 +423,16 @@ Generator cache creates timing differences between cache hits and misses, potent
397423

398424
The kMOSAIC implementation has been assessed and critical security issues have been remediated:
399425

400-
1. **VULN-001 (TDD Plaintext):** Now uses XOR encryption with keystream derived from the masked tensor matrix
401-
2. **VULN-002 (EGRW Randomness):** Randomness no longer exposed; ephemeral walk vertex used instead
402-
3. **VULN-004 (Modular Bias):** Rejection sampling now ensures uniform distribution
426+
1. **VULN-001 (TDD Plaintext):** Now uses XOR encryption with keystream derived from the masked tensor matrix2. **VULN-002 (EGRW randomness exposure):** Now derives ciphertext endpoints from ephemeral walks and does not expose randomness
427+
2. **VULN-004 (Modular bias):** Rejection sampling implemented in TDD sampling
428+
3. **VULN-014 (Decapsulation oracle):** Decapsulation hardened to return implicit-reject values on malformed or tampered ciphertexts; added unit tests to verify behavior
429+
430+
Additional improvements:
431+
432+
- Added `isNativeShake256Available()` and README guidance to make SHAKE256 availability explicit for production deployments.
433+
- Added robust unit tests for malformed/corrupted ciphertext handling: `test/kem-malformed.test.ts` (proof tampering, malformed fragments, truncated ciphertexts, publicKey mismatch).
434+
435+
Overall, the most critical issues have been remediated and the codebase now includes tests that guard against malformed ciphertext behavior and oracle leakage. Continuous monitoring and peer review are recommended for the remaining acknowledged limitations (timing, zeroization limits, and JS runtime concerns).2. **VULN-002 (EGRW Randomness):** Randomness no longer exposed; ephemeral walk vertex used instead 3. **VULN-004 (Modular Bias):** Rejection sampling now ensures uniform distribution
403436

404437
The remaining acknowledged items are primarily JavaScript runtime limitations that are well-documented in the code and do not constitute exploitable vulnerabilities in typical deployment scenarios.
405438

src/kem/index.ts

Lines changed: 147 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -365,41 +365,108 @@ export async function decapsulate(
365365

366366
// Compute implicit rejection value first (constant-time protection)
367367
// This is returned if any validation fails
368-
const ciphertextBytes = serializeCiphertext(ciphertext)
368+
let ciphertextBytes: Uint8Array
369+
try {
370+
ciphertextBytes = serializeCiphertext(ciphertext)
371+
} catch {
372+
// Malformed ciphertext serialization — use empty buffer and mark invalid
373+
ciphertextBytes = new Uint8Array(0)
374+
}
375+
369376
const implicitRejectSecret = shake256(
370377
hashWithDomain(DOMAIN_IMPLICIT_REJECT, hashConcat(seed, ciphertextBytes)),
371378
32,
372379
)
373380

374381
let validDecapsulation = 1 // 1 = valid, 0 = invalid
375382

376-
// Decrypt each fragment
377-
const share1 = slssDecrypt(c1, slssSK, params.slss)
378-
const share2 = tddDecrypt(c2, tddSK, params.tdd)
379-
const share3 = egrwDecrypt(c3, egrwSK, egrwPK, params.egrw)
383+
// Quick sanity: ensure public key matches secret key's recorded hash
384+
try {
385+
const pkHash = sha3_256(serializePublicKey(publicKey))
386+
if (!constantTimeEqual(pkHash, publicKeyHash)) {
387+
validDecapsulation = 0
388+
}
389+
} catch {
390+
// If serialization of public key fails, mark invalid but continue
391+
validDecapsulation = 0
392+
}
380393

381-
// Reconstruct ephemeral secret
382-
const recoveredSecret = secretReconstruct([share1, share2, share3])
394+
// Decrypt each fragment with safe failure handling
395+
let share1: Uint8Array = new Uint8Array(32)
396+
let share2: Uint8Array = new Uint8Array(32)
397+
let share3: Uint8Array = new Uint8Array(32)
398+
399+
try {
400+
const s1 = slssDecrypt(c1, slssSK, params.slss)
401+
if (s1.length === 32) share1 = s1
402+
else {
403+
validDecapsulation = 0
404+
}
405+
} catch {
406+
validDecapsulation = 0
407+
}
383408

384-
// Fujisaki-Okamoto re-encryption check
385-
// Re-encapsulate with recovered secret and verify ciphertext matches
386-
const reEncapsulated = encapsulateDeterministic(publicKey, recoveredSecret)
387-
const reEncapsulatedBytes = serializeCiphertext(reEncapsulated.ciphertext)
409+
try {
410+
const s2 = tddDecrypt(c2, tddSK, params.tdd)
411+
if (s2.length === 32) share2 = s2
412+
else {
413+
validDecapsulation = 0
414+
}
415+
} catch {
416+
validDecapsulation = 0
417+
}
388418

389-
// Constant-time comparison of ciphertexts
390-
if (!constantTimeEqual(ciphertextBytes, reEncapsulatedBytes)) {
419+
try {
420+
const s3 = egrwDecrypt(c3, egrwSK, egrwPK, params.egrw)
421+
if (s3.length === 32) share3 = s3
422+
else {
423+
validDecapsulation = 0
424+
}
425+
} catch {
391426
validDecapsulation = 0
392427
}
393428

394-
// Verify NIZK proof (additional check)
395-
const proof = deserializeNIZKProof(proofBytes)
396-
const ciphertextHashes = [
397-
sha3_256(serializeSLSSCiphertext(c1)),
398-
sha3_256(serializeTDDCiphertext(c2)),
399-
sha3_256(serializeEGRWCiphertext(c3)),
400-
]
429+
// Reconstruct ephemeral secret (shares are normalized to 32 bytes)
430+
let recoveredSecret: Uint8Array
431+
try {
432+
recoveredSecret = secretReconstruct([share1, share2, share3])
433+
} catch {
434+
// Reconstruction failure — use zeroed secret and mark invalid
435+
recoveredSecret = new Uint8Array(32)
436+
validDecapsulation = 0
437+
}
401438

402-
if (!verifyNIZKProof(proof, ciphertextHashes, recoveredSecret)) {
439+
// Fujisaki-Okamoto re-encryption check (compare hashes to avoid length leaks)
440+
let reEncapsulatedBytes: Uint8Array
441+
try {
442+
const reEncapsulated = encapsulateDeterministic(publicKey, recoveredSecret)
443+
reEncapsulatedBytes = serializeCiphertext(reEncapsulated.ciphertext)
444+
} catch {
445+
reEncapsulatedBytes = new Uint8Array(0)
446+
validDecapsulation = 0
447+
}
448+
449+
// Compare fixed-length hashes (constant-time)
450+
const originalCtHash = sha3_256(ciphertextBytes)
451+
const reCtHash = sha3_256(reEncapsulatedBytes)
452+
if (!constantTimeEqual(originalCtHash, reCtHash)) {
453+
validDecapsulation = 0
454+
}
455+
456+
// Verify NIZK proof (additional check)
457+
try {
458+
const proof = deserializeNIZKProof(proofBytes)
459+
const ciphertextHashes = [
460+
sha3_256(serializeSLSSCiphertext(c1)),
461+
sha3_256(serializeTDDCiphertext(c2)),
462+
sha3_256(serializeEGRWCiphertext(c3)),
463+
]
464+
465+
if (!verifyNIZKProof(proof, ciphertextHashes, recoveredSecret)) {
466+
validDecapsulation = 0
467+
}
468+
} catch {
469+
// Any failure in proof parsing or verification marks invalid
403470
validDecapsulation = 0
404471
}
405472

@@ -730,17 +797,38 @@ export function serializeCiphertext(ct: MOSAICCiphertext): Uint8Array {
730797
* @returns Ciphertext object
731798
*/
732799
export function deserializeCiphertext(data: Uint8Array): MOSAICCiphertext {
800+
// Basic bounds checks
801+
if (data.length < 4) throw new Error('Invalid ciphertext: too short')
802+
733803
const view = new DataView(data.buffer, data.byteOffset)
734804
let offset = 0
735805

736806
// c1
807+
if (offset + 4 > data.length)
808+
throw new Error('Invalid ciphertext: truncated c1 length')
737809
const c1Len = view.getUint32(offset, true)
738810
offset += 4
811+
const MAX_PART = 8 * 1024 * 1024 // 8 MB per component to prevent resource exhaustion (supports MOS-256 public keys)
812+
if (c1Len <= 0 || c1Len > MAX_PART || offset + c1Len > data.length)
813+
throw new Error('Invalid ciphertext: c1 extends beyond data or too large')
739814
const c1Start = offset
740815
const c1View = new DataView(data.buffer, data.byteOffset + c1Start)
816+
817+
// Validate SLSS component structure
818+
if (c1Len < 8) throw new Error('Invalid SLSS ciphertext: too short')
741819
const uLen = c1View.getUint32(0, true)
742-
const u = new Int32Array(data.buffer, data.byteOffset + c1Start + 4, uLen / 4)
820+
if (uLen % 4 !== 0)
821+
throw new Error('Invalid SLSS ciphertext: u length not multiple of 4')
822+
if (4 + uLen + 4 > c1Len)
823+
throw new Error('Invalid SLSS ciphertext: malformed lengths')
824+
743825
const vLen = c1View.getUint32(4 + uLen, true)
826+
if (4 + uLen + 4 + vLen !== c1Len)
827+
throw new Error('Invalid SLSS ciphertext: length mismatch')
828+
if (vLen % 4 !== 0)
829+
throw new Error('Invalid SLSS ciphertext: v length not multiple of 4')
830+
831+
const u = new Int32Array(data.buffer, data.byteOffset + c1Start + 4, uLen / 4)
744832
const v = new Int32Array(
745833
data.buffer,
746834
data.byteOffset + c1Start + 8 + uLen,
@@ -749,13 +837,19 @@ export function deserializeCiphertext(data: Uint8Array): MOSAICCiphertext {
749837
offset += c1Len
750838

751839
// c2
840+
if (offset + 4 > data.length)
841+
throw new Error('Invalid ciphertext: truncated c2 length')
752842
const c2Len = view.getUint32(offset, true)
753843
offset += 4
844+
if (c2Len <= 0 || c2Len > MAX_PART || offset + c2Len > data.length)
845+
throw new Error('Invalid ciphertext: c2 extends beyond data or too large')
754846
const c2Start = offset
755-
const c2DataLen = new DataView(
756-
data.buffer,
757-
data.byteOffset + c2Start,
758-
).getUint32(0, true)
847+
const c2View = new DataView(data.buffer, data.byteOffset + c2Start)
848+
const c2DataLen = c2View.getUint32(0, true)
849+
if (4 + c2DataLen !== c2Len)
850+
throw new Error('Invalid TDD ciphertext: length mismatch')
851+
if (c2DataLen % 4 !== 0)
852+
throw new Error('Invalid TDD ciphertext: data length not multiple of 4')
759853
const tddData = new Int32Array(
760854
data.buffer,
761855
data.byteOffset + c2Start + 4,
@@ -764,8 +858,12 @@ export function deserializeCiphertext(data: Uint8Array): MOSAICCiphertext {
764858
offset += c2Len
765859

766860
// c3
861+
if (offset + 4 > data.length)
862+
throw new Error('Invalid ciphertext: truncated c3 length')
767863
const c3Len = view.getUint32(offset, true)
768864
offset += 4
865+
if (c3Len <= 16 || c3Len > MAX_PART || offset + c3Len > data.length)
866+
throw new Error('Invalid EGRW ciphertext: malformed c3 or too large')
769867
const c3Start = offset
770868
const vertexView = new DataView(data.buffer, data.byteOffset + c3Start)
771869
const vertex = {
@@ -851,38 +949,60 @@ export function serializePublicKey(pk: MOSAICPublicKey): Uint8Array {
851949
* Format: [level_len:4][level_string][slss_len:4][slss_data][tdd_len:4][tdd_data][egrw_len:4][egrw_data][binding:32]
852950
*/
853951
export function deserializePublicKey(data: Uint8Array): MOSAICPublicKey {
952+
// Basic bounds check
953+
if (data.length < 4) throw new Error('Invalid public key: too short')
954+
854955
const view = new DataView(data.buffer, data.byteOffset)
855956
let offset = 0
856957

857958
// Read security level string
959+
if (offset + 4 > data.length)
960+
throw new Error('Invalid public key: truncated level length')
858961
const levelLen = view.getUint32(offset, true)
859962
offset += 4
963+
if (levelLen <= 0 || offset + levelLen > data.length || levelLen > 255)
964+
throw new Error('Invalid public key: level length invalid')
965+
860966
const levelBytes = data.slice(offset, offset + levelLen)
861967
const level = new TextDecoder().decode(levelBytes) as SecurityLevel
862968
offset += levelLen
863969

864-
// Get params from level
970+
// Get params from level (may throw if level unknown)
865971
const params = getParams(level)
866972

867973
// Read SLSS public key
974+
if (offset + 4 > data.length)
975+
throw new Error('Invalid public key: truncated SLSS length')
868976
const slssLen = view.getUint32(offset, true)
869977
offset += 4
978+
if (slssLen <= 0 || offset + slssLen > data.length)
979+
throw new Error('Invalid public key: SLSS component out of bounds')
870980
const slss = slssDeserializePublicKey(data.slice(offset, offset + slssLen))
871981
offset += slssLen
872982

873983
// Read TDD public key
984+
if (offset + 4 > data.length)
985+
throw new Error('Invalid public key: truncated TDD length')
874986
const tddLen = view.getUint32(offset, true)
875987
offset += 4
988+
if (tddLen <= 0 || offset + tddLen > data.length)
989+
throw new Error('Invalid public key: TDD component out of bounds')
876990
const tdd = tddDeserializePublicKey(data.slice(offset, offset + tddLen))
877991
offset += tddLen
878992

879993
// Read EGRW public key
994+
if (offset + 4 > data.length)
995+
throw new Error('Invalid public key: truncated EGRW length')
880996
const egrwLen = view.getUint32(offset, true)
881997
offset += 4
998+
if (egrwLen <= 0 || offset + egrwLen > data.length)
999+
throw new Error('Invalid public key: EGRW component out of bounds')
8821000
const egrw = egrwDeserializePublicKey(data.slice(offset, offset + egrwLen))
8831001
offset += egrwLen
8841002

8851003
// Read binding (fixed 32 bytes)
1004+
if (offset + 32 > data.length)
1005+
throw new Error('Invalid public key: missing binding')
8861006
const binding = data.slice(offset, offset + 32)
8871007

8881008
return { slss, tdd, egrw, binding, params }

src/problems/egrw/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,8 @@ export function egrwSerializePublicKey(pk: EGRWPublicKey): Uint8Array {
486486
* @returns Public key
487487
*/
488488
export function egrwDeserializePublicKey(data: Uint8Array): EGRWPublicKey {
489+
if (data.length < 32)
490+
throw new Error('Invalid EGRW public key: expected 32 bytes')
489491
const vStart = bytesToSl2(data.slice(0, 16))
490492
const vEnd = bytesToSl2(data.slice(16, 32))
491493
return { vStart, vEnd }

0 commit comments

Comments
 (0)