Skip to content

Add ML-DSA (FIPS 204) post-quantum signature support - #651

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

Add ML-DSA (FIPS 204) post-quantum signature support#651
ffang wants to merge 3 commits into
apache:mainfrom
ffang:PQC-SIGNATURE

Conversation

@ffang

@ffang ffang commented Aug 21, 2026

Copy link
Copy Markdown

Adds ML-DSA-44/65/87 XML digital signature support via the JSR-105 API (DOM) and the STAX signature path, wired through JCEMapper and the JSR-105 provider's algorithm URI registrations. Part of the post-quantum work tracked under SANTUARIO-634 (originally proposed in SANTUARIO-633 / #645), split out here as the signature-only half per community request.

Adds ML-DSA-44/65/87 XML digital signature support via the JSR-105
API (DOM) and the STAX signature path, wired through JCEMapper and
the JSR-105 provider's algorithm URI registrations. Part of the
post-quantum work tracked under SANTUARIO-634 (originally proposed
in SANTUARIO-633 / apache#645), split
out here as the signature-only half per community request.

- The ML-DSA test keystore is generated on the fly per test run
  instead of a committed PKCS12 binary, avoiding the maintenance
  burden of binary test fixtures. Uses SelfSignedCertGenerator,
  originally authored by Joze Rihtarsic (unmerged PR apache#617), copied
  in and extended here with ML-DSA-44/65/87 AlgorithmIdentifier
  support per his suggestion on apache#645.
- Adds negative-test coverage on both the DOM/JSR-105 and STAX
  paths: a tampered SignatureValue is rejected, and verification
  against the wrong public key fails. Added per Arpan0995's review
  feedback on apache#645.
if (provider == null) {
String providerId = JCEMapper.getProviderId();
if (providerId == null) {
this.signatureAlgorithm = Signature.getInstance(algorithmID);
if (provider == null) {
String providerId = JCEMapper.getProviderId();
if (providerId == null) {
this.signatureAlgorithm = Signature.getInstance(algorithmID);
if (providerId == null) {
this.signatureAlgorithm = Signature.getInstance(algorithmID);
} else {
this.signatureAlgorithm = Signature.getInstance(algorithmID, providerId);
if (providerId == null) {
this.signatureAlgorithm = Signature.getInstance(algorithmID);
} else {
this.signatureAlgorithm = Signature.getInstance(algorithmID, providerId);
this.signatureAlgorithm = Signature.getInstance(algorithmID, providerId);
}
} else {
this.signatureAlgorithm = Signature.getInstance(algorithmID, provider);
this.signatureAlgorithm = Signature.getInstance(algorithmID, providerId);
}
} else {
this.signatureAlgorithm = Signature.getInstance(algorithmID, provider);
Arpan0995 and others added 2 commits August 21, 2026 12:55
… sets

Converts the tampered-SignatureValue and wrong-public-key rejection
tests on both the DOM/JSR-105 and StAX paths from single hardcoded
ML-DSA-65 cases to @ParameterizedTest/@CsvSource across ML-DSA-44/65/87,
matching the style of the existing sign-and-verify tests. The StAX
helper takes the signature and key algorithms as parameters instead of
hardcoding ML-DSA-65.
Parameterize the negative signature tests across all ML-DSA parameter sets
@Arpan0995

Copy link
Copy Markdown

While adding inbound-path coverage after the rebase, I found that StAX verification of an ML-DSA signature is not exercised by the current tests, and driving it revealed a round-trip issue with the KeyValue key identifier. Everything below was reproduced on this branch.

The issue. With setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue), which is the identifier StaxMLDSASignatureTest itself configures, outbound signing with an ML-DSA key succeeds but emits an empty key element:

<dsig:KeyInfo xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" Id="..."><dsig:KeyValue/></dsig:KeyInfo>

Feeding that document back through the library's own inbound side (InboundXMLSec#processInMessage) then fails schema validation and never reaches signature verification, even when the signature is valid:

XMLSecurityException: cvc-complex-type.2.4.b: The content of element 'dsig:KeyValue' is not complete.
One of '{...DSAKeyValue, ...RSAKeyValue, WC[##other:"http://www.w3.org/2000/09/xmldsig#"]}' is expected.

Root cause. XMLSecurityUtils#createKeyValueTokenStructure(AbstractOutputProcessor, OutputProcessorChain, PublicKey) opens the dsig:KeyValue element, then dispatches on publicKey.getAlgorithm() with branches for "RSA", "DSA" and "EC" only. For any other key type it falls through all branches and closes the element, so an ML-DSA key produces <dsig:KeyValue/> with no content. (The writer predates this PR and behaves this way for any key type outside those three; this PR is what makes the fall-through reachable, in the very configuration its own test uses.)

The inbound side of this PR is otherwise sound. Repeating the same round trip with KeyIdentifier_NoKeyInfo and a verification key supplied via setSignatureVerificationKey, the inbound path behaves exactly as it should for ML-DSA-65: the valid document verifies, a tampered SignatureValue is rejected ("INVALID signature -- core validation failed"), and verification against a wrong public key is rejected the same way. So the gap is confined to the KeyValue serialization, not to the new signature wiring.

Why the tests do not catch it: StaxMLDSASignatureTest signs through the outbound path but verifies through the DOM engine (verifyUsingDOM / XMLSignature#checkSignatureValue), so the empty KeyValue is never consumed by the inbound processor.

Two possible fixes, in order of preference:

  1. Emit dsig11:DEREncodedKeyValue (the SubjectPublicKeyInfo encoding) for key types that have no structured KeyValue form. XML Signature 1.1 defines it as a direct KeyInfo child, and it would equally be schema-valid inside dsig:KeyValue through the same ##other wildcard this writer already relies on for dsig11:ECKeyValue. Carrying an arbitrary public key is exactly what that element exists for, and this PR already adds the ML-DSA names to the DOM DEREncodedKeyValue class, so the resolving side has a basis.
  2. Failing that, throw at signing time when KeyIdentifier_KeyValue is requested for an unsupported key type, rather than emitting XML the library itself will not accept back.

Happy to contribute the inbound-path tests either way (valid round trip, tampered signature, wrong key, all through processInMessage, parameterized across the three parameter sets); they would have caught this and would pin the inbound behavior against future refactors. Related note on the encryption split's inbound coverage is on #652.

@ffang

ffang commented Aug 21, 2026

Copy link
Copy Markdown
Author

While adding inbound-path coverage after the rebase, I found that StAX verification of an ML-DSA signature is not exercised by the current tests, and driving it revealed a round-trip issue with the KeyValue key identifier. Everything below was reproduced on this branch.

The issue. With setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue), which is the identifier StaxMLDSASignatureTest itself configures, outbound signing with an ML-DSA key succeeds but emits an empty key element:

<dsig:KeyInfo xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" Id="..."><dsig:KeyValue/></dsig:KeyInfo>

Feeding that document back through the library's own inbound side (InboundXMLSec#processInMessage) then fails schema validation and never reaches signature verification, even when the signature is valid:

XMLSecurityException: cvc-complex-type.2.4.b: The content of element 'dsig:KeyValue' is not complete.
One of '{...DSAKeyValue, ...RSAKeyValue, WC[##other:"http://www.w3.org/2000/09/xmldsig#"]}' is expected.

Root cause. XMLSecurityUtils#createKeyValueTokenStructure(AbstractOutputProcessor, OutputProcessorChain, PublicKey) opens the dsig:KeyValue element, then dispatches on publicKey.getAlgorithm() with branches for "RSA", "DSA" and "EC" only. For any other key type it falls through all branches and closes the element, so an ML-DSA key produces <dsig:KeyValue/> with no content. (The writer predates this PR and behaves this way for any key type outside those three; this PR is what makes the fall-through reachable, in the very configuration its own test uses.)

The inbound side of this PR is otherwise sound. Repeating the same round trip with KeyIdentifier_NoKeyInfo and a verification key supplied via setSignatureVerificationKey, the inbound path behaves exactly as it should for ML-DSA-65: the valid document verifies, a tampered SignatureValue is rejected ("INVALID signature -- core validation failed"), and verification against a wrong public key is rejected the same way. So the gap is confined to the KeyValue serialization, not to the new signature wiring.

Why the tests do not catch it: StaxMLDSASignatureTest signs through the outbound path but verifies through the DOM engine (verifyUsingDOM / XMLSignature#checkSignatureValue), so the empty KeyValue is never consumed by the inbound processor.

Two possible fixes, in order of preference:

1. Emit `dsig11:DEREncodedKeyValue` (the SubjectPublicKeyInfo encoding) for key types that have no structured KeyValue form. XML Signature 1.1 defines it as a direct `KeyInfo` child, and it would equally be schema-valid inside `dsig:KeyValue` through the same `##other` wildcard this writer already relies on for `dsig11:ECKeyValue`. Carrying an arbitrary public key is exactly what that element exists for, and this PR already adds the ML-DSA names to the DOM `DEREncodedKeyValue` class, so the resolving side has a basis.

Hi @Arpan0995 ,

Agree that "Emit DEREncodedKeyValue " is better and please go this way.

2. Failing that, throw at signing time when `KeyIdentifier_KeyValue` is requested for an unsupported key type, rather than emitting XML the library itself will not accept back.

Happy to contribute the inbound-path tests either way (valid round trip, tampered signature, wrong key, all through processInMessage, parameterized across the three parameter sets); they would have caught this and would pin the inbound behavior against future refactors.
Thanks a lot!
Related note on the encryption split's inbound coverage is on #652.

@Arpan0995

Arpan0995 commented Aug 21, 2026

Copy link
Copy Markdown

@ffang Done: the DEREncodedKeyValue fix is up as ffang#4 into this branch, with a parameterized test across ML-DSA-44/65/87. As noted there, it makes the KeyValue schema-valid and the embedded key recoverable; full StAX-inbound round-trip additionally needs inbound DEREncodedKeyValue extraction, which I am happy to follow up with separately.

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