Skip to content

Add ML-KEM (FIPS 203) post-quantum key transport support - #652

Open
ffang wants to merge 3 commits into
apache:mainfrom
ffang:PQC-ENCRYPTION
Open

Add ML-KEM (FIPS 203) post-quantum key transport support#652
ffang wants to merge 3 commits into
apache:mainfrom
ffang:PQC-ENCRYPTION

Conversation

@ffang

@ffang ffang commented Aug 21, 2026

Copy link
Copy Markdown

Adds ML-KEM-512/768/1024 key transport via the W3C "XML Security: Generic Hybrid Cipher" structure, on both the DOM and STAX encryption paths. Part of the post-quantum work tracked under SANTUARIO-634 (originally proposed in SANTUARIO-633 / #645), split out here as the encryption-only half per community request.

Adds negative-test coverage on both the DOM and STAX paths: decryption with the wrong recipient's ML-KEM private key fails cleanly, and a truncated EncryptedKey CipherValue is rejected via the existing length check in KeyUtils#kemDecapsulate. Added per Arpan0995's review feedback on #645.

Adds ML-KEM-512/768/1024 key transport via the W3C "XML Security:
Generic Hybrid Cipher" structure, on both the DOM and STAX
encryption paths. Part of the post-quantum work tracked under
SANTUARIO-634 (originally proposed in SANTUARIO-633 /
apache#645), split out here as the
encryption-only half per community request.

Adds negative-test coverage on both the DOM and STAX paths:
decryption with the wrong recipient's ML-KEM private key fails
cleanly, and a truncated EncryptedKey CipherValue is rejected via
the existing length check in KeyUtils#kemDecapsulate. Added per
Arpan0995's review feedback on apache#645.
KeyUtils.KemDecapsulation kemResult = KeyUtils.kemDecapsulate(
(PrivateKey) wrapKeyToken, kemAlgorithm, encryptedBytes, kdp);
String jceWrapId = JCEMapper.translateURItoJCEID(dataEncapsulationAlgorithm);
Cipher cipher = Cipher.getInstance(jceWrapId);
KeyUtils.KemDecapsulation kemResult = KeyUtils.kemDecapsulate(
(PrivateKey) wrapKeyToken, kemAlgorithm, encryptedBytes, kdp);
String jceWrapId = JCEMapper.translateURItoJCEID(dataEncapsulationAlgorithm);
Cipher cipher = Cipher.getInstance(jceWrapId);
KeyUtils.KemEncapsulation kemResult = KeyUtils.kemEncapsulate(pubKey, kemAlgorithm, kdfParams);
try {
String jceWrapId = JCEMapper.translateURItoJCEID(dataEncapsulationAlgorithm);
Cipher wrapCipher = Cipher.getInstance(jceWrapId);
KeyUtils.KemEncapsulation kemResult = KeyUtils.kemEncapsulate(pubKey, kemAlgorithm, kdfParams);
try {
String jceWrapId = JCEMapper.translateURItoJCEID(dataEncapsulationAlgorithm);
Cipher wrapCipher = Cipher.getInstance(jceWrapId);
Arpan0995 and others added 2 commits August 21, 2026 12:55
…r sets

Converts the wrong-recipient-key and truncated-encapsulation rejection
tests on both the DOM and StAX paths from single hardcoded ML-KEM-768
cases to @ParameterizedTest/@CsvSource across ML-KEM-512/768/1024,
matching the style of the existing encrypt-decrypt tests. The
encryptToRecipient helpers take the key encapsulation algorithm as a
parameter instead of hardcoding ML-KEM-768.
Parameterize the negative encryption tests across all ML-KEM parameter sets
@Arpan0995

Copy link
Copy Markdown

While looking further at the StAX side after the rebase, I noticed a coverage seam in the negative tests, including the two I just parameterized, and verified the actual inbound behavior locally on this branch.

The StAX negative tests do not exercise the StAX inbound handler. testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly and testMLKEMStaxTruncatedEncapsulationRejected both decrypt through the decryptUsingDOM helper, so the failure path they pin down is the DOM XMLCipher one (decryptKey throwing XMLEncryptionException). The new inbound branch in XMLEncryptedKeyInputHandler#getGenericHybridSecret is only reached by the happy-path testMLKEMStaxEncryptStaxDecrypt.

The two paths reject differently. I ran both failure cases through the real inbound path (InboundXMLSec#processInMessage) on this branch:

  • Wrong recipient private key: no error at the key-transport layer. Decryption proceeds with the substituted random CEK from the fake-key fallback, and the failure surfaces later as an XMLStreamException whose cause chain ends in AEADBadTagException: mac check in GCM failed.
  • Truncated CipherValue (shorter than encapsulationSize()): same outcome. The length check in KeyUtils#kemDecapsulate does throw, but the surrounding catch (Exception e) in getGenericHybridSecret converts that into the fake random key too, so the structural error is also deferred to the GCM tag check.

I assume the fake-key fallback is deliberate, since it mirrors the existing RSA key-transport behavior in the same handler and gives uniform timing. Two thoughts on it:

  1. It means the StAX path's rejection of a bad EncryptedKey relies on the content cipher being authenticated. With AES-GCM that holds. It may be worth a short code comment stating that assumption, since with a non-AEAD content algorithm rejection would depend on padding failures or on garbage plaintext failing XML parsing rather than on an authenticated integrity check.
  2. For the truncation case specifically, the fallback may be broader than intended: a ciphertext shorter than encapsulationSize() is structurally invalid rather than a wrong-key guess, and the DOM path rejects it cleanly with XMLEncryptionException. Rethrowing the XMLSecurityException from kemDecapsulate instead of falling through to the fake key would align the two paths there, without affecting the timing story for well-formed ciphertexts.

Either way, it seems worth pinning the inbound behavior with negative tests so a future refactor cannot silently turn "wrong key" into "successful decrypt". Happy to contribute those in the same parameterized shape as the merged ones: wrong-key and truncated-ciphertext driven through processInMessage, asserting the failure.

The signature split has the same seam (its StAX tests verify through the DOM engine rather than processInMessage), and driving the inbound path there surfaced an actual round-trip issue with the KeyValue key identifier; I have written that up separately on #651.

@ffang

ffang commented Aug 21, 2026

Copy link
Copy Markdown
Author

While looking further at the StAX side after the rebase, I noticed a coverage seam in the negative tests, including the two I just parameterized, and verified the actual inbound behavior locally on this branch.

The StAX negative tests do not exercise the StAX inbound handler. testMLKEMStaxWrongRecipientPrivateKeyFailsCleanly and testMLKEMStaxTruncatedEncapsulationRejected both decrypt through the decryptUsingDOM helper, so the failure path they pin down is the DOM XMLCipher one (decryptKey throwing XMLEncryptionException). The new inbound branch in XMLEncryptedKeyInputHandler#getGenericHybridSecret is only reached by the happy-path testMLKEMStaxEncryptStaxDecrypt.

The two paths reject differently. I ran both failure cases through the real inbound path (InboundXMLSec#processInMessage) on this branch:

* Wrong recipient private key: no error at the key-transport layer. Decryption proceeds with the substituted random CEK from the fake-key fallback, and the failure surfaces later as an `XMLStreamException` whose cause chain ends in `AEADBadTagException: mac check in GCM failed`.

* Truncated `CipherValue` (shorter than `encapsulationSize()`): same outcome. The length check in `KeyUtils#kemDecapsulate` does throw, but the surrounding `catch (Exception e)` in `getGenericHybridSecret` converts that into the fake random key too, so the structural error is also deferred to the GCM tag check.

I assume the fake-key fallback is deliberate, since it mirrors the existing RSA key-transport behavior in the same handler and gives uniform timing. Two thoughts on it:

1. It means the StAX path's rejection of a bad `EncryptedKey` relies on the content cipher being authenticated. With AES-GCM that holds. It may be worth a short code comment stating that assumption, since with a non-AEAD content algorithm rejection would depend on padding failures or on garbage plaintext failing XML parsing rather than on an authenticated integrity check.

2. For the truncation case specifically, the fallback may be broader than intended: a ciphertext shorter than `encapsulationSize()` is structurally invalid rather than a wrong-key guess, and the DOM path rejects it cleanly with `XMLEncryptionException`. Rethrowing the `XMLSecurityException` from `kemDecapsulate` instead of falling through to the fake key would align the two paths there, without affecting the timing story for well-formed ciphertexts.

Either way, it seems worth pinning the inbound behavior with negative tests so a future refactor cannot silently turn "wrong key" into "successful decrypt". Happy to contribute those in the same parameterized shape as the merged ones: wrong-key and truncated-ciphertext driven through processInMessage, asserting the failure.

Hi @Arpan0995 ,

Yes, please add tests in terms of this.

Thanks!
Freeman

The signature split has the same seam (its StAX tests verify through the DOM engine rather than processInMessage), and driving the inbound path there surfaced an actual round-trip issue with the KeyValue key identifier; I have written that up separately on #651.

@Arpan0995

Arpan0995 commented Aug 21, 2026

Copy link
Copy Markdown

@ffang Done: the StAX inbound negative tests are up as ffang#5 into this branch, driving the wrong-recipient-key and truncated-encapsulation rejections through processInMessage, parameterized across ML-KEM-512/768/1024.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants