From 042d5f7a324c991059f42dfaaff9fe5e01298cbb Mon Sep 17 00:00:00 2001 From: Freeman Fang Date: Fri, 21 Aug 2026 09:49:35 -0400 Subject: [PATCH 1/6] Add ML-DSA (FIPS 204) post-quantum signature support 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/santuario-xml-security-java#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 #617), copied in and extended here with ML-DSA-44/65/87 AlgorithmIdentifier support per his suggestion on #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 #645. --- .../dom/AbstractDOMSignatureMethod.java | 2 +- .../dsig/internal/dom/DOMSignatureMethod.java | 99 ++++ .../internal/dom/DOMXMLSignatureFactory.java | 8 +- .../xml/security/algorithms/JCEMapper.java | 12 + .../algorithms/SignatureAlgorithm.java | 10 + .../implementations/SignatureMLDSA.java | 208 ++++++++ .../keys/content/DEREncodedKeyValue.java | 1 + .../xml/security/signature/XMLSignature.java | 12 + .../algorithms/PKISignatureAlgorithm.java | 4 +- .../AbstractInboundSecurityToken.java | 10 +- src/main/resources/security-config.xml | 22 + .../xml/crypto/dsig/XMLSignatureAbstract.java | 15 + .../crypto/dsig/XMLSignatureMLDSATest.java | 205 ++++++++ .../signature/StaxMLDSASignatureTest.java | 190 +++++++ .../testutils/SelfSignedCertGenerator.java | 480 ++++++++++++++++++ 15 files changed, 1272 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/apache/xml/security/algorithms/implementations/SignatureMLDSA.java create mode 100644 src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureMLDSATest.java create mode 100644 src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java create mode 100644 src/test/java/org/apache/xml/security/testutils/SelfSignedCertGenerator.java diff --git a/src/main/java/org/apache/jcp/xml/dsig/internal/dom/AbstractDOMSignatureMethod.java b/src/main/java/org/apache/jcp/xml/dsig/internal/dom/AbstractDOMSignatureMethod.java index 9728d20aa..9e2f7c0ac 100644 --- a/src/main/java/org/apache/jcp/xml/dsig/internal/dom/AbstractDOMSignatureMethod.java +++ b/src/main/java/org/apache/jcp/xml/dsig/internal/dom/AbstractDOMSignatureMethod.java @@ -47,7 +47,7 @@ abstract class AbstractDOMSignatureMethod extends DOMStructure implements SignatureMethod { // denotes the type of signature algorithm - enum Type { DSA, RSA, ECDSA, EDDSA, HMAC } + enum Type { DSA, RSA, ECDSA, EDDSA, MLDSA, HMAC } /** * Verifies the passed-in signature with the specified key, using the diff --git a/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMSignatureMethod.java b/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMSignatureMethod.java index 688ab7668..83d8eb179 100644 --- a/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMSignatureMethod.java +++ b/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMSignatureMethod.java @@ -94,6 +94,16 @@ public abstract class DOMSignatureMethod extends AbstractDOMSignatureMethod { "http://www.w3.org/2021/04/xmldsig-more#eddsa-ed25519"; static final String ED448 = "http://www.w3.org/2021/04/xmldsig-more#eddsa-ed448"; + + // Provisional URIs for ML-DSA (FIPS 204) per draft-eastlake-rfc9231bis-xmlsec-uris + // section 3.3.15. These use the draft's "tbd" placeholder namespace and will need + // to be updated once final URIs are assigned (see SANTUARIO-634). + static final String ML_DSA_44 = + "http://www.w3.org/tbd#ml-dsa-44"; + static final String ML_DSA_65 = + "http://www.w3.org/tbd#ml-dsa-65"; + static final String ML_DSA_87 = + "http://www.w3.org/tbd#ml-dsa-87"; static final String ECDSA_SHA3_224 = "http://www.w3.org/2021/04/xmldsig-more#ecdsa-sha3-224"; static final String ECDSA_SHA3_256 = @@ -269,6 +279,12 @@ static SignatureMethod unmarshal(Element smElem) throws MarshalException { return new EDDSA_ED25519(smElem); } else if (alg.equals(ED448)) { return new EDDSA_ED448(smElem); + } else if (alg.equals(ML_DSA_44)) { + return new MLDSA_44(smElem); + } else if (alg.equals(ML_DSA_65)) { + return new MLDSA_65(smElem); + } else if (alg.equals(ML_DSA_87)) { + return new MLDSA_87(smElem); } else { throw new MarshalException ("unsupported SignatureMethod algorithm: " + alg); @@ -1291,4 +1307,87 @@ String getJCAAlgorithm() { return "Ed448"; } } + + abstract static class AbstractMLDSASignatureMethod extends DOMSignatureMethod { + + AbstractMLDSASignatureMethod(AlgorithmParameterSpec params) + throws InvalidAlgorithmParameterException { + super(params); + } + + AbstractMLDSASignatureMethod(Element dmElem) throws MarshalException { + super(dmElem); + } + + /** ML-DSA signatures are raw bytes; no reformatting needed. */ + @Override + byte[] postSignFormat(Key key, byte[] sig) { + return sig; + } + + /** ML-DSA signatures are raw bytes; no reformatting needed. */ + @Override + byte[] preVerifyFormat(Key key, byte[] sig) { + return sig; + } + + @Override + Type getAlgorithmType() { + return Type.MLDSA; + } + } + + static final class MLDSA_44 extends AbstractMLDSASignatureMethod { + MLDSA_44(AlgorithmParameterSpec params) + throws InvalidAlgorithmParameterException { + super(params); + } + MLDSA_44(Element dmElem) throws MarshalException { + super(dmElem); + } + @Override + public String getAlgorithm() { + return ML_DSA_44; + } + @Override + String getJCAAlgorithm() { + return "ML-DSA-44"; + } + } + + static final class MLDSA_65 extends AbstractMLDSASignatureMethod { + MLDSA_65(AlgorithmParameterSpec params) + throws InvalidAlgorithmParameterException { + super(params); + } + MLDSA_65(Element dmElem) throws MarshalException { + super(dmElem); + } + @Override + public String getAlgorithm() { + return ML_DSA_65; + } + @Override + String getJCAAlgorithm() { + return "ML-DSA-65"; + } + } + + static final class MLDSA_87 extends AbstractMLDSASignatureMethod { + MLDSA_87(AlgorithmParameterSpec params) + throws InvalidAlgorithmParameterException { + super(params); + } + MLDSA_87(Element dmElem) throws MarshalException { + super(dmElem); + } + @Override + public String getAlgorithm() { + return ML_DSA_87; + } + @Override + String getJCAAlgorithm() { + return "ML-DSA-87"; + } + } } diff --git a/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMXMLSignatureFactory.java b/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMXMLSignatureFactory.java index 49f93514c..94074a211 100644 --- a/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMXMLSignatureFactory.java +++ b/src/main/java/org/apache/jcp/xml/dsig/internal/dom/DOMXMLSignatureFactory.java @@ -355,7 +355,13 @@ public SignatureMethod newSignatureMethod(String algorithm, return new DOMSignatureMethod.EDDSA_ED25519(params); } else if (algorithm.equals(DOMSignatureMethod.ED448)) { return new DOMSignatureMethod.EDDSA_ED448(params); - }else { + } else if (algorithm.equals(DOMSignatureMethod.ML_DSA_44)) { + return new DOMSignatureMethod.MLDSA_44(params); + } else if (algorithm.equals(DOMSignatureMethod.ML_DSA_65)) { + return new DOMSignatureMethod.MLDSA_65(params); + } else if (algorithm.equals(DOMSignatureMethod.ML_DSA_87)) { + return new DOMSignatureMethod.MLDSA_87(params); + } else { throw new NoSuchAlgorithmException("unsupported algorithm"); } } diff --git a/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java b/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java index 1dc09759d..2826f6597 100644 --- a/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java +++ b/src/main/java/org/apache/xml/security/algorithms/JCEMapper.java @@ -233,6 +233,18 @@ public static void registerDefaultAlgorithms() { XMLSignature.ALGO_ID_SIGNATURE_EDDSA_ED448, new Algorithm("Ed448", "Ed448", "Signature") ); + algorithmsMap.put( + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_44, + new Algorithm("ML-DSA-44", "ML-DSA-44", "Signature") + ); + algorithmsMap.put( + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65, + new Algorithm("ML-DSA-65", "ML-DSA-65", "Signature") + ); + algorithmsMap.put( + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_87, + new Algorithm("ML-DSA-87", "ML-DSA-87", "Signature") + ); algorithmsMap.put( XMLSignature.ALGO_ID_MAC_HMAC_NOT_RECOMMENDED_MD5, new Algorithm("", "HmacMD5", "Mac", 0, 0) diff --git a/src/main/java/org/apache/xml/security/algorithms/SignatureAlgorithm.java b/src/main/java/org/apache/xml/security/algorithms/SignatureAlgorithm.java index 578e1eb1b..a0ae148a6 100644 --- a/src/main/java/org/apache/xml/security/algorithms/SignatureAlgorithm.java +++ b/src/main/java/org/apache/xml/security/algorithms/SignatureAlgorithm.java @@ -34,6 +34,7 @@ import org.apache.xml.security.algorithms.implementations.SignatureDSA; import org.apache.xml.security.algorithms.implementations.SignatureECDSA; import org.apache.xml.security.algorithms.implementations.SignatureEDDSA; +import org.apache.xml.security.algorithms.implementations.SignatureMLDSA; import org.apache.xml.security.exceptions.AlgorithmAlreadyRegisteredException; import org.apache.xml.security.exceptions.XMLSecurityException; import org.apache.xml.security.signature.XMLSignature; @@ -513,6 +514,15 @@ public static void registerDefaultAlgorithms() { algorithmHash.put( XMLSignature.ALGO_ID_SIGNATURE_EDDSA_ED448, SignatureEDDSA.SignatureEd448.class ); + algorithmHash.put( + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_44, SignatureMLDSA.SignatureMLDSA44.class + ); + algorithmHash.put( + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65, SignatureMLDSA.SignatureMLDSA65.class + ); + algorithmHash.put( + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_87, SignatureMLDSA.SignatureMLDSA87.class + ); algorithmHash.put( XMLSignature.ALGO_ID_MAC_HMAC_NOT_RECOMMENDED_MD5, IntegrityHmac.IntegrityHmacMD5.class ); diff --git a/src/main/java/org/apache/xml/security/algorithms/implementations/SignatureMLDSA.java b/src/main/java/org/apache/xml/security/algorithms/implementations/SignatureMLDSA.java new file mode 100644 index 000000000..daeef2db0 --- /dev/null +++ b/src/main/java/org/apache/xml/security/algorithms/implementations/SignatureMLDSA.java @@ -0,0 +1,208 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.algorithms.implementations; + +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.security.InvalidAlgorithmParameterException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +import java.security.spec.AlgorithmParameterSpec; + +import org.apache.xml.security.algorithms.JCEMapper; +import org.apache.xml.security.algorithms.SignatureAlgorithmSpi; +import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.signature.XMLSignatureException; +import org.apache.xml.security.utils.XMLUtils; + +/** + * ML-DSA (FIPS 204) signature algorithm implementation for XML-Dsig. + * Supports ML-DSA-44 (NIST security level 2), ML-DSA-65 (level 3), + * and ML-DSA-87 (level 5). Requires BouncyCastle 1.81+ as the JCA provider. + */ +public abstract class SignatureMLDSA extends SignatureAlgorithmSpi { + + private static final Logger LOG = System.getLogger(SignatureMLDSA.class.getName()); + + private final Signature signatureAlgorithm; + + public SignatureMLDSA() throws XMLSignatureException { + this(null); + } + + public SignatureMLDSA(Provider provider) throws XMLSignatureException { + String algorithmID = JCEMapper.translateURItoJCEID(this.engineGetURI()); + LOG.log(Level.DEBUG, "Created SignatureMLDSA using {0}", algorithmID); + + try { + if (provider == null) { + String providerId = JCEMapper.getProviderId(); + if (providerId == null) { + this.signatureAlgorithm = Signature.getInstance(algorithmID); + } else { + this.signatureAlgorithm = Signature.getInstance(algorithmID, providerId); + } + } else { + this.signatureAlgorithm = Signature.getInstance(algorithmID, provider); + } + } catch (NoSuchAlgorithmException | NoSuchProviderException ex) { + Object[] exArgs = { algorithmID, ex.getLocalizedMessage() }; + throw new XMLSignatureException("algorithms.NoSuchAlgorithm", exArgs); + } + } + + @Override + protected void engineSetParameter(AlgorithmParameterSpec params) throws XMLSignatureException { + try { + this.signatureAlgorithm.setParameter(params); + } catch (InvalidAlgorithmParameterException ex) { + throw new XMLSignatureException(ex); + } + } + + @Override + protected boolean engineVerify(byte[] signature) throws XMLSignatureException { + try { + LOG.log(Level.DEBUG, () -> "Called SignatureMLDSA.verify() on " + XMLUtils.encodeToString(signature)); + return this.signatureAlgorithm.verify(signature); + } catch (SignatureException ex) { + throw new XMLSignatureException(ex); + } + } + + @Override + protected void engineInitVerify(Key publicKey) throws XMLSignatureException { + engineInitVerify(publicKey, signatureAlgorithm); + } + + @Override + protected byte[] engineSign() throws XMLSignatureException { + try { + return this.signatureAlgorithm.sign(); + } catch (SignatureException ex) { + throw new XMLSignatureException(ex); + } + } + + @Override + protected void engineInitSign(Key privateKey, SecureRandom secureRandom) + throws XMLSignatureException { + engineInitSign(privateKey, secureRandom, this.signatureAlgorithm); + } + + @Override + protected void engineInitSign(Key privateKey) throws XMLSignatureException { + engineInitSign(privateKey, (SecureRandom) null); + } + + @Override + protected void engineUpdate(byte[] input) throws XMLSignatureException { + try { + this.signatureAlgorithm.update(input); + } catch (SignatureException ex) { + throw new XMLSignatureException(ex); + } + } + + @Override + protected void engineUpdate(byte input) throws XMLSignatureException { + try { + this.signatureAlgorithm.update(input); + } catch (SignatureException ex) { + throw new XMLSignatureException(ex); + } + } + + @Override + protected void engineUpdate(byte[] buf, int offset, int len) throws XMLSignatureException { + try { + this.signatureAlgorithm.update(buf, offset, len); + } catch (SignatureException ex) { + throw new XMLSignatureException(ex); + } + } + + @Override + protected String engineGetJCEAlgorithmString() { + return this.signatureAlgorithm.getAlgorithm(); + } + + @Override + protected String engineGetJCEProviderName() { + return this.signatureAlgorithm.getProvider().getName(); + } + + @Override + protected void engineSetHMACOutputLength(int HMACOutputLength) throws XMLSignatureException { + throw new XMLSignatureException("algorithms.HMACOutputLengthOnlyForHMAC"); + } + + @Override + protected void engineInitSign(Key signingKey, AlgorithmParameterSpec algorithmParameterSpec) + throws XMLSignatureException { + throw new XMLSignatureException("algorithms.CannotUseAlgorithmParameterSpecOnEdDSA"); + } + + /** ML-DSA-44 — NIST security level 2. */ + public static class SignatureMLDSA44 extends SignatureMLDSA { + public SignatureMLDSA44() throws XMLSignatureException { + super(); + } + public SignatureMLDSA44(Provider provider) throws XMLSignatureException { + super(provider); + } + @Override + public String engineGetURI() { + return XMLSignature.ALGO_ID_SIGNATURE_MLDSA_44; + } + } + + /** ML-DSA-65 — NIST security level 3. */ + public static class SignatureMLDSA65 extends SignatureMLDSA { + public SignatureMLDSA65() throws XMLSignatureException { + super(); + } + public SignatureMLDSA65(Provider provider) throws XMLSignatureException { + super(provider); + } + @Override + public String engineGetURI() { + return XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65; + } + } + + /** ML-DSA-87 — NIST security level 5. */ + public static class SignatureMLDSA87 extends SignatureMLDSA { + public SignatureMLDSA87() throws XMLSignatureException { + super(); + } + public SignatureMLDSA87(Provider provider) throws XMLSignatureException { + super(provider); + } + @Override + public String engineGetURI() { + return XMLSignature.ALGO_ID_SIGNATURE_MLDSA_87; + } + } +} diff --git a/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java b/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java index a5c402578..85be2f938 100644 --- a/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java +++ b/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java @@ -40,6 +40,7 @@ public class DEREncodedKeyValue extends Signature11ElementProxy implements KeyIn private static final String[] supportedKeyTypes = { "RSA", "DSA", "EC", "DiffieHellman", "DH", "XDH", "X25519", "X448", "EdDSA", "Ed25519", "Ed448", + "ML-DSA-44", "ML-DSA-65", "ML-DSA-87", "RSASSA-PSS"}; /** diff --git a/src/main/java/org/apache/xml/security/signature/XMLSignature.java b/src/main/java/org/apache/xml/security/signature/XMLSignature.java index 8e1fa9e9d..9110db529 100644 --- a/src/main/java/org/apache/xml/security/signature/XMLSignature.java +++ b/src/main/java/org/apache/xml/security/signature/XMLSignature.java @@ -211,6 +211,18 @@ public final class XMLSignature extends SignatureElementProxy { public static final String ALGO_ID_SIGNATURE_EDDSA_ED448 = "http://www.w3.org/2021/04/xmldsig-more#eddsa-ed448"; + /**Signature - ML-DSA-44 (FIPS 204, provisional URI per draft-eastlake-rfc9231bis-xmlsec-uris section 3.3.15; not yet finalized, see SANTUARIO-634) */ + public static final String ALGO_ID_SIGNATURE_MLDSA_44 = + "http://www.w3.org/tbd#ml-dsa-44"; + + /**Signature - ML-DSA-65 (FIPS 204, provisional URI per draft-eastlake-rfc9231bis-xmlsec-uris section 3.3.15; not yet finalized, see SANTUARIO-634) */ + public static final String ALGO_ID_SIGNATURE_MLDSA_65 = + "http://www.w3.org/tbd#ml-dsa-65"; + + /**Signature - ML-DSA-87 (FIPS 204, provisional URI per draft-eastlake-rfc9231bis-xmlsec-uris section 3.3.15; not yet finalized, see SANTUARIO-634) */ + public static final String ALGO_ID_SIGNATURE_MLDSA_87 = + "http://www.w3.org/tbd#ml-dsa-87"; + /**Signature - SHA3-224withECDSA */ public static final String ALGO_ID_SIGNATURE_ECDSA_SHA3_224 = diff --git a/src/main/java/org/apache/xml/security/stax/impl/algorithms/PKISignatureAlgorithm.java b/src/main/java/org/apache/xml/security/stax/impl/algorithms/PKISignatureAlgorithm.java index 5cc5ddbd7..13a406c69 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/algorithms/PKISignatureAlgorithm.java +++ b/src/main/java/org/apache/xml/security/stax/impl/algorithms/PKISignatureAlgorithm.java @@ -126,7 +126,7 @@ public byte[] engineSign() throws XMLSecurityException { byte[] jcebytes = signature.sign(); if (this.jceName.contains("ECDSA")) { return ECDSAUtils.convertASN1toXMLDSIG(jcebytes, signIntLen); - } else if (this.jceName.contains("DSA")) { + } else if (this.jceName.contains("DSA") && !this.jceName.startsWith("ML-DSA")) { return JavaUtils.convertDsaASN1toXMLDSIG(jcebytes, 20); } return jcebytes; @@ -152,7 +152,7 @@ public boolean engineVerify(byte[] signature) throws XMLSecurityException { byte[] jcebytes = signature; if (this.jceName.contains("ECDSA")) { jcebytes = ECDSAUtils.convertXMLDSIGtoASN1(jcebytes); - } else if (this.jceName.contains("DSA")) { + } else if (this.jceName.contains("DSA") && !this.jceName.startsWith("ML-DSA")) { jcebytes = JavaUtils.convertDsaXMLDSIGtoASN1(jcebytes, 20); } return this.signature.verify(jcebytes); diff --git a/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java b/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java index 9aacd281d..66d737093 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java +++ b/src/main/java/org/apache/xml/security/stax/impl/securityToken/AbstractInboundSecurityToken.java @@ -19,6 +19,7 @@ package org.apache.xml.security.stax.impl.securityToken; import java.security.Key; +import java.security.PrivateKey; import java.security.PublicKey; import java.security.interfaces.DSAKey; import java.security.interfaces.ECKey; @@ -139,6 +140,10 @@ public final Key getSecretKey(String algorithmURI, XMLSecurityConstants.Algorith algorithmSuiteSecurityEvent.setKeyLength(((ECKey) key).getParams().getOrder().bitLength()); } else if (key instanceof SecretKey) { algorithmSuiteSecurityEvent.setKeyLength(key.getEncoded().length * 8); + } else if (key instanceof PrivateKey) { + // PQC or other asymmetric key types (e.g. ML-KEM): key length not classically defined + byte[] encoded = key.getEncoded(); + algorithmSuiteSecurityEvent.setKeyLength(encoded != null ? encoded.length * 8 : 0); } else { throw new XMLSecurityException("java.security.UnknownKeyType", new Object[] {key.getClass().getName()}); @@ -174,8 +179,9 @@ public final PublicKey getPublicKey(String algorithmURI, XMLSecurityConstants.Al } else if (publicKey instanceof ECKey) { algorithmSuiteSecurityEvent.setKeyLength(((ECKey) publicKey).getParams().getOrder().bitLength()); } else { - throw new XMLSecurityException("java.security.UnknownKeyType", - new Object[] {publicKey.getClass().getName()}); + // PQC or other asymmetric public key types (e.g. ML-DSA, ML-KEM): key length not classically defined + byte[] encoded = publicKey.getEncoded(); + algorithmSuiteSecurityEvent.setKeyLength(encoded != null ? encoded.length * 8 : 0); } inboundSecurityContext.registerSecurityEvent(algorithmSuiteSecurityEvent); } diff --git a/src/main/resources/security-config.xml b/src/main/resources/security-config.xml index f6c91db07..dc4a92879 100644 --- a/src/main/resources/security-config.xml +++ b/src/main/resources/security-config.xml @@ -321,6 +321,28 @@ RequiredKey="EC" JCEName="RIPEMD160withECDSA"/> + + + + + + + Key pairs and self-signed certificates are generated on the fly for each of + * ML-DSA-44/65/87 via {@link SelfSignedCertGenerator}, rather than loading a + * pre-generated keystore committed as a binary test resource (see SANTUARIO-634). + * The test requires BouncyCastle on the runtime classpath to supply the ML-DSA + * JCA provider; compile-time BC classes are deliberately avoided so the default + * build (without {@code -P bouncycastle}) still compiles cleanly. + * + *

Run with the Maven {@code bouncycastle} profile: + *

mvn test -Dtest=XMLSignatureMLDSATest -P bouncycastle
+ */ +class XMLSignatureMLDSATest extends XMLSignatureAbstract { + + static final char[] KEY_PASSWORD = "security".toCharArray(); + + private static boolean mlDsaAvailable; + private static boolean bcAddedForTheTest; + private static KeyStore keyStore; + + @BeforeAll + static void setUp() { + Security.insertProviderAt( + new org.apache.jcp.xml.dsig.internal.dom.XMLDSigRI(), 1); + + if (Security.getProvider("BC") == null) { + try { + Class cls = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider"); + Provider bc = (Provider) cls.getConstructor().newInstance(); + Security.addProvider(bc); + bcAddedForTheTest = true; + } catch (ReflectiveOperationException e) { + mlDsaAvailable = false; + return; + } + } + + try { + keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + for (String alias : new String[]{"ml-dsa-44", "ml-dsa-65", "ml-dsa-87"}) { + String jcaAlgorithm = alias.toUpperCase(); + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "BC"); + KeyPair keyPair = kpg.generateKeyPair(); + X509Certificate cert = SelfSignedCertGenerator.generate( + keyPair, jcaAlgorithm, "CN=Test " + jcaAlgorithm + ",O=Apache Santuario,C=US", 365); + keyStore.setKeyEntry(alias, keyPair.getPrivate(), KEY_PASSWORD, new Certificate[]{cert}); + } + mlDsaAvailable = true; + } catch (Exception e) { + mlDsaAvailable = false; + } + } + + @AfterAll + static void tearDown() { + if (bcAddedForTheTest) { + Security.removeProvider("BC"); + } + } + + @ParameterizedTest + @CsvSource({ + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_44 + ",ml-dsa-44", + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65 + ",ml-dsa-65", + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_87 + ",ml-dsa-87", + }) + void testMLDSASignAndVerify(String signatureAlgorithmURI, String alias) throws Exception { + Assumptions.assumeTrue(mlDsaAvailable, "ML-DSA requires BouncyCastle 1.81+"); + byte[] signedXml = doSignWithJcpApi(signatureAlgorithmURI, alias, false); + Assertions.assertNotNull(signedXml); + assertValidSignatureWithJcpApi(signedXml, false); + } + + @Test + void testMLDSATamperedSignatureRejected() throws Exception { + Assumptions.assumeTrue(mlDsaAvailable, "ML-DSA requires BouncyCastle 1.81+"); + byte[] signedXml = doSignWithJcpApi(XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65, "ml-dsa-65", false); + + byte[] tamperedXml = flipByteInSignatureValue(signedXml); + + boolean coreValidity = validateSignatureWithJcpApi(tamperedXml, new KeySelectors.RawX509KeySelector()); + Assertions.assertFalse(coreValidity, "A tampered SignatureValue must not validate"); + } + + @Test + void testMLDSAWrongPublicKeyRejected() throws Exception { + Assumptions.assumeTrue(mlDsaAvailable, "ML-DSA requires BouncyCastle 1.81+"); + byte[] signedXml = doSignWithJcpApi(XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65, "ml-dsa-65", false); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-DSA-65", "BC"); + PublicKey wrongPublicKey = kpg.generateKeyPair().getPublic(); + + KeySelector wrongKeySelector = new KeySelector() { + @Override + public KeySelectorResult select(KeyInfo keyInfo, Purpose purpose, AlgorithmMethod method, + XMLCryptoContext context) throws KeySelectorException { + return () -> wrongPublicKey; + } + }; + + boolean coreValidity = validateSignatureWithJcpApi(signedXml, wrongKeySelector); + Assertions.assertFalse(coreValidity, "Verification against the wrong public key must not validate"); + } + + /** + * Decodes the <SignatureValue> text content, flips one byte, and re-serializes - + * simulates an attacker (or transport bug) corrupting the signature bytes while leaving + * the rest of the document, including the embedded certificate, intact. + */ + private byte[] flipByteInSignatureValue(byte[] signedXml) throws Exception { + Document doc; + try (ByteArrayInputStream is = new ByteArrayInputStream(signedXml)) { + doc = XMLUtils.read(is, false); + } + NodeList sigValues = doc.getElementsByTagNameNS(Constants.SignatureSpecNS, "SignatureValue"); + Assertions.assertEquals(1, sigValues.getLength(), "Expected exactly one SignatureValue element"); + Element sigValueElement = (Element) sigValues.item(0); + + byte[] sigBytes = Base64.getMimeDecoder().decode(sigValueElement.getTextContent()); + sigBytes[sigBytes.length / 2] ^= (byte) 0xFF; + String tamperedBase64 = Base64.getEncoder().encodeToString(sigBytes); + + // Replace the SignatureValue element's text content in place + NodeList children = sigValueElement.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + sigValueElement.removeChild(children.item(i)); + } + Text newText = doc.createTextNode(tamperedBase64); + sigValueElement.appendChild(newText); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + XMLUtils.outputDOMc14nWithComments(doc, bos); + return bos.toByteArray(); + } + + @Override + KeyStore getKeyStore() { + return keyStore; + } + + @Override + char[] getKeyPassword() { + return KEY_PASSWORD; + } +} diff --git a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java new file mode 100644 index 000000000..3758a16a7 --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java @@ -0,0 +1,190 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.test.stax.signature; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; + +import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.stax.ext.SecurePart; +import org.apache.xml.security.stax.ext.XMLSecurityConstants; +import org.apache.xml.security.stax.ext.XMLSecurityProperties; +import org.apache.xml.security.stax.securityToken.SecurityTokenConstants; +import org.apache.xml.security.utils.Constants; +import org.apache.xml.security.utils.XMLUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.w3c.dom.Text; + +/** + * StAX-path tests for ML-DSA (FIPS 204) XML digital signatures. + */ +class StaxMLDSASignatureTest extends AbstractSignatureCreationTest { + + private static final Map keyPairs = new HashMap<>(); + + @BeforeAll + static void generateKeys() throws Exception { + if (!isBcInstalled()) { + return; + } + try { + for (String alg : new String[]{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"}) { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg, "BC"); + keyPairs.put(alg, kpg.generateKeyPair()); + } + } catch (Exception e) { + // ML-DSA not available with this BC version + } + } + + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testMLDSASign(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), + "ML-DSA requires BouncyCastle 1.81+"); + + XMLSecurityProperties properties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.SIGNATURE); + properties.setActions(actions); + properties.setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue); + properties.setSignatureAlgorithm(sigAlgorithm); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + properties.setSignatureKey(kp.getPrivate()); + properties.setSignatureVerificationKey(kp.getPublic()); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), + SecurePart.Modifier.Content, + new String[]{"http://www.w3.org/2001/10/xml-exc-c14n#"}, + "http://www.w3.org/2001/04/xmlenc#sha256"); + properties.addSignaturePart(securePart); + + byte[] output = process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", properties, null); + + Document document; + try (InputStream is = new ByteArrayInputStream(output)) { + document = XMLUtils.read(is, false); + } + + verifyUsingDOM(document, kp.getPublic(), properties.getSignatureSecureParts()); + } + + @Test + void testMLDSAStaxTamperedSignatureRejected() throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey("ML-DSA-65"), + "ML-DSA requires BouncyCastle 1.81+"); + + Document document = signWithMLDSA65(); + Element sigElement = tamperSignatureValue(document); + + XMLSignature signature = new XMLSignature(sigElement, ""); + boolean coreValidity = signature.checkSignatureValue(keyPairs.get("ML-DSA-65").getPublic()); + Assertions.assertFalse(coreValidity, "A tampered SignatureValue must not validate"); + } + + @Test + void testMLDSAStaxWrongPublicKeyRejected() throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey("ML-DSA-65"), + "ML-DSA requires BouncyCastle 1.81+"); + + Document document = signWithMLDSA65(); + Element sigElement = (Element) document.getElementsByTagNameNS(Constants.SignatureSpecNS, "Signature").item(0); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-DSA-65", "BC"); + PublicKey wrongPublicKey = kpg.generateKeyPair().getPublic(); + + XMLSignature signature = new XMLSignature(sigElement, ""); + boolean coreValidity = signature.checkSignatureValue(wrongPublicKey); + Assertions.assertFalse(coreValidity, "Verification against the wrong public key must not validate"); + } + + private Document signWithMLDSA65() throws Exception { + XMLSecurityProperties properties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.SIGNATURE); + properties.setActions(actions); + properties.setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue); + properties.setSignatureAlgorithm("http://www.w3.org/tbd#ml-dsa-65"); + + KeyPair kp = keyPairs.get("ML-DSA-65"); + properties.setSignatureKey(kp.getPrivate()); + properties.setSignatureVerificationKey(kp.getPublic()); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), + SecurePart.Modifier.Content, + new String[]{"http://www.w3.org/2001/10/xml-exc-c14n#"}, + "http://www.w3.org/2001/04/xmlenc#sha256"); + properties.addSignaturePart(securePart); + + byte[] output = process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", properties, null); + + try (InputStream is = new ByteArrayInputStream(output)) { + return XMLUtils.read(is, false); + } + } + + /** + * Decodes the <SignatureValue> text content, flips one byte, and writes it back - + * simulates an attacker (or transport bug) corrupting the signature bytes while leaving + * the rest of the document intact. Returns the enclosing <Signature> element. + */ + private Element tamperSignatureValue(Document document) { + NodeList sigValues = document.getElementsByTagNameNS(Constants.SignatureSpecNS, "SignatureValue"); + Assertions.assertEquals(1, sigValues.getLength(), "Expected exactly one SignatureValue element"); + Element sigValueElement = (Element) sigValues.item(0); + + byte[] sigBytes = Base64.getMimeDecoder().decode(sigValueElement.getTextContent()); + sigBytes[sigBytes.length / 2] ^= (byte) 0xFF; + String tamperedBase64 = Base64.getEncoder().encodeToString(sigBytes); + + NodeList children = sigValueElement.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + sigValueElement.removeChild(children.item(i)); + } + Text newText = document.createTextNode(tamperedBase64); + sigValueElement.appendChild(newText); + + return (Element) sigValueElement.getParentNode(); + } +} diff --git a/src/test/java/org/apache/xml/security/testutils/SelfSignedCertGenerator.java b/src/test/java/org/apache/xml/security/testutils/SelfSignedCertGenerator.java new file mode 100644 index 000000000..74f08fa43 --- /dev/null +++ b/src/test/java/org/apache/xml/security/testutils/SelfSignedCertGenerator.java @@ -0,0 +1,480 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.testutils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.Signature; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Set; + +/** + * Generates minimal self-signed X.509 v3 certificates using only public JDK APIs. + * + *

The certificate's DER structure is constructed directly from ASN.1/DER primitives + * and then parsed using CertificateFactory. No BouncyCastle, no sun.security.* internals, + * and no --add-opens flags are required. + * This class is designed to eliminate the need for storing test certificates in a keystore + * or truststore. Instead, the certificates are generated dynamically during test execution. + *

+ *

Supported signature algorithms

+ *
    + *
  • RSA — {@code SHA256withRSA}, {@code SHA384withRSA}, {@code SHA512withRSA}
  • + *
  • ECDSA — {@code SHA256withECDSA}, {@code SHA384withECDSA}, {@code SHA512withECDSA}
  • + *
  • EdDSA — {@code Ed25519}, {@code Ed448} (requires Java 15+)
  • + *
  • ML-DSA (FIPS 204, requires BouncyCastle) — {@code ML-DSA-44}, {@code ML-DSA-65}, {@code ML-DSA-87}
  • + *
+ * + *

Supported DN attributes

+ *
    + *
  • {@code CN} — commonName (UTF8String)
  • + *
  • {@code C} — countryName (PrintableString, two-letter ISO 3166 code)
  • + *
  • {@code O} — organizationName (UTF8String)
  • + *
  • {@code OU} — organizationalUnitName (UTF8String)
  • + *
+ * + *

Limitations

+ *
    + *
  • No X.509 extensions are added (basic-constraints, key-usage, etc.).
  • + *
  • Validity dates use UTCTime, which covers years 2000–2049.
  • + *
+ * + *

These are acceptable constraints for unit and integration tests. + * + *

Adapted from Joze Rihtarsic's {@code SelfSignedCertGenerator} utility contributed in + * https://github.com/apache/santuario-xml-security-java/pull/617, and extended here with + * ML-DSA (FIPS 204) support per his suggestion on SANTUARIO-634 to avoid committing binary + * keystores as test resources. + */ +public final class SelfSignedCertGenerator { + + private SelfSignedCertGenerator() { + } + + // ------------------------------------------------------------------------- + // ASN.1 universal tag constants (ITU-T X.690) + // ------------------------------------------------------------------------- + + private static final int TAG_INTEGER = 0x02; + private static final int TAG_BIT_STRING = 0x03; + private static final int TAG_OID = 0x06; + private static final int TAG_UTF8_STRING = 0x0C; + private static final int TAG_PRINTABLE_STRING = 0x13; + private static final int TAG_UTC_TIME = 0x17; + private static final int TAG_SEQUENCE = 0x30; + private static final int TAG_SET = 0x31; + /** Context-specific constructed [0] tag — used for the TBSCertificate version field. */ + private static final int TAG_CONTEXT_0 = 0xA0; + + // ------------------------------------------------------------------------- + // Pre-built DER encoding constants + // ------------------------------------------------------------------------- + + /** DER encoding of ASN.1 NULL (05 00). */ + private static final byte[] DER_NULL = {0x05, 0x00}; + + /** + * DER encoding of TBSCertificate {@code version} field set to v3 (INTEGER value 2) + * wrapped in an [0] EXPLICIT context tag. + */ + private static final byte[] TBS_VERSION_V3 = { + (byte) TAG_CONTEXT_0, 0x03, (byte) TAG_INTEGER, 0x01, 0x02 + }; + + // ------------------------------------------------------------------------- + // Signature algorithm OID strings + // ------------------------------------------------------------------------- + + /** SHA-256 with RSA Encryption — RFC 4055, OID 1.2.840.113549.1.1.11 */ + private static final String OID_SHA256_WITH_RSA = "1.2.840.113549.1.1.11"; + /** SHA-384 with RSA Encryption — RFC 4055, OID 1.2.840.113549.1.1.12 */ + private static final String OID_SHA384_WITH_RSA = "1.2.840.113549.1.1.12"; + /** SHA-512 with RSA Encryption — RFC 4055, OID 1.2.840.113549.1.1.13 */ + private static final String OID_SHA512_WITH_RSA = "1.2.840.113549.1.1.13"; + /** ECDSA with SHA-256 — RFC 5758, OID 1.2.840.10045.4.3.2 */ + private static final String OID_SHA256_WITH_ECDSA = "1.2.840.10045.4.3.2"; + /** ECDSA with SHA-384 — RFC 5758, OID 1.2.840.10045.4.3.3 */ + private static final String OID_SHA384_WITH_ECDSA = "1.2.840.10045.4.3.3"; + /** ECDSA with SHA-512 — RFC 5758, OID 1.2.840.10045.4.3.4 */ + private static final String OID_SHA512_WITH_ECDSA = "1.2.840.10045.4.3.4"; + /** Ed25519 — RFC 8410, OID 1.3.101.112 */ + private static final String OID_ED25519 = "1.3.101.112"; + /** Ed448 — RFC 8410, OID 1.3.101.113 */ + private static final String OID_ED448 = "1.3.101.113"; + /** ML-DSA-44 — NIST CSOR / draft-ietf-lamps-dilithium-certificates, OID 2.16.840.1.101.3.4.3.17 */ + private static final String OID_ML_DSA_44 = "2.16.840.1.101.3.4.3.17"; + /** ML-DSA-65 — NIST CSOR / draft-ietf-lamps-dilithium-certificates, OID 2.16.840.1.101.3.4.3.18 */ + private static final String OID_ML_DSA_65 = "2.16.840.1.101.3.4.3.18"; + /** ML-DSA-87 — NIST CSOR / draft-ietf-lamps-dilithium-certificates, OID 2.16.840.1.101.3.4.3.19 */ + private static final String OID_ML_DSA_87 = "2.16.840.1.101.3.4.3.19"; + + /** + * OIDs whose AlgorithmIdentifier MUST have absent (not NULL) parameters — the EdDSA arc + * (RFC 8410 §6) and the ML-DSA OIDs (draft-ietf-lamps-dilithium-certificates §5.1). + */ + private static final Set NO_PARAMS_ALGORITHMS = Set.of( + OID_ED25519, OID_ED448, OID_ML_DSA_44, OID_ML_DSA_65, OID_ML_DSA_87); + + // ------------------------------------------------------------------------- + // X.500 attribute type OID strings (RFC 4519) + // ------------------------------------------------------------------------- + + /** commonName — OID 2.5.4.3 */ + private static final String OID_COMMON_NAME = "2.5.4.3"; + /** countryName — OID 2.5.4.6 */ + private static final String OID_COUNTRY_NAME = "2.5.4.6"; + /** organizationName — OID 2.5.4.10 */ + private static final String OID_ORGANIZATION_NAME = "2.5.4.10"; + /** organizationalUnitName — OID 2.5.4.11 */ + private static final String OID_ORGANIZATIONAL_UNIT_NAME = "2.5.4.11"; + + // ------------------------------------------------------------------------- + // Pre-encoded DER OID bytes for RDN attribute types + // ------------------------------------------------------------------------- + + private static final byte[] OID_BYTES_CN = encodeOid(OID_COMMON_NAME); + private static final byte[] OID_BYTES_C = encodeOid(OID_COUNTRY_NAME); + private static final byte[] OID_BYTES_O = encodeOid(OID_ORGANIZATION_NAME); + private static final byte[] OID_BYTES_OU = encodeOid(OID_ORGANIZATIONAL_UNIT_NAME); + + /** + * Pre-encoded DER bytes for the {@code AlgorithmIdentifier} of each supported + * signature algorithm. Values are constant per the relevant RFCs; they do not + * depend on the key size or curve, only on the algorithm name. + * + *

RSA and ECDSA algorithms include a trailing {@code NULL} parameters element + * (RFC 4055 §3.2, conventionally also used for ECDSA). EdDSA and ML-DSA algorithms + * omit parameters entirely (RFC 8410; draft-ietf-lamps-dilithium-certificates §5.1). + */ + private static final Map ALG_IDS = Map.ofEntries( + Map.entry("SHA256withRSA", encodeAlgorithmIdentifier(OID_SHA256_WITH_RSA)), + Map.entry("SHA384withRSA", encodeAlgorithmIdentifier(OID_SHA384_WITH_RSA)), + Map.entry("SHA512withRSA", encodeAlgorithmIdentifier(OID_SHA512_WITH_RSA)), + Map.entry("SHA256withECDSA", encodeAlgorithmIdentifier(OID_SHA256_WITH_ECDSA)), + Map.entry("SHA384withECDSA", encodeAlgorithmIdentifier(OID_SHA384_WITH_ECDSA)), + Map.entry("SHA512withECDSA", encodeAlgorithmIdentifier(OID_SHA512_WITH_ECDSA)), + Map.entry("Ed25519", encodeAlgorithmIdentifier(OID_ED25519)), + Map.entry("Ed448", encodeAlgorithmIdentifier(OID_ED448)), + Map.entry("ML-DSA-44", encodeAlgorithmIdentifier(OID_ML_DSA_44)), + Map.entry("ML-DSA-65", encodeAlgorithmIdentifier(OID_ML_DSA_65)), + Map.entry("ML-DSA-87", encodeAlgorithmIdentifier(OID_ML_DSA_87))); + + /** + * Generates a self-signed X.509 v3 certificate. + * + * @param keyPair the key pair to certify; the private key signs the TBS structure + * and the public key is embedded in SubjectPublicKeyInfo + * @param signatureAlgorithm JCA algorithm name, e.g. {@code "SHA256withRSA"} or {@code "Ed25519"} + * @param subjectDN distinguished name with supported attributes: CN, C, O, OU, + * e.g. {@code "CN=Test Certificate,O=Acme,C=US"} + * @param validityDays number of days the certificate is valid, starting from now + * @return the signed X.509 certificate + * @throws IllegalArgumentException if {@code signatureAlgorithm} is not in the supported set + */ + public static X509Certificate generate(KeyPair keyPair, + String signatureAlgorithm, + String subjectDN, + int validityDays) throws Exception { + byte[] algId = ALG_IDS.get(signatureAlgorithm); + if (algId == null) { + throw new IllegalArgumentException( + "Unsupported signature algorithm: " + signatureAlgorithm + + ". Supported: " + ALG_IDS.keySet()); + } + + // publicKey.getEncoded() returns the SubjectPublicKeyInfo in X.509/DER format. + byte[] spki = keyPair.getPublic().getEncoded(); + byte[] name = encodeName(subjectDN); + byte[] tbs = buildTbs(algId, name, spki, validityDays); + + Signature signer = Signature.getInstance(signatureAlgorithm); + signer.initSign(keyPair.getPrivate()); + signer.update(tbs); + byte[] sigBytes = signer.sign(); + + // Certificate ::= SEQUENCE { TBSCertificate, AlgorithmIdentifier, BIT STRING } + byte[] certDer = sequence(cat(tbs, algId, bitString(sigBytes))); + + return (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(certDer)); + } + + // ------------------------------------------------------------------------- + // TBSCertificate builder + // ------------------------------------------------------------------------- + + /** + * Builds the DER-encoded TBSCertificate. + * + *

+     * TBSCertificate ::= SEQUENCE {
+     *   version         [0] EXPLICIT INTEGER DEFAULT v1,
+     *   serialNumber    INTEGER,
+     *   signature       AlgorithmIdentifier,
+     *   issuer          Name,
+     *   validity        Validity,
+     *   subject         Name,
+     *   subjectPublicKeyInfo SubjectPublicKeyInfo
+     * }
+     * 
+ */ + private static byte[] buildTbs(byte[] algId, byte[] name, + byte[] spki, int validityDays) { + // Serial: milliseconds since epoch — unique enough for test certs + byte[] serial = integer(BigInteger.valueOf(System.currentTimeMillis())); + byte[] validity = buildValidity(validityDays); + // issuer == subject for self-signed + return sequence(cat(TBS_VERSION_V3, serial, algId, name, validity, name, spki)); + } + + private static byte[] buildValidity(int validityDays) { + Instant notBefore = Instant.now(); + Instant notAfter = notBefore.plusSeconds(validityDays * 86_400L); + return sequence(cat(utcTime(notBefore), utcTime(notAfter))); + } + + // ------------------------------------------------------------------------- + // DN encoding — CN, C, O, OU attributes (RFC 4519) + // ------------------------------------------------------------------------- + + /** + * Encodes a Name containing a single CN attribute. + * + *
+     * Name ::= SEQUENCE OF SET OF SEQUENCE { OID, value }
+     * 
+ */ + private static byte[] encodeName(String dn) { + ByteArrayOutputStream rdns = new ByteArrayOutputStream(); + for (String part : dn.split(",")) { + String trimmed = part.strip(); + int eq = trimmed.indexOf('='); + if (eq < 0) continue; + String key = trimmed.substring(0, eq).strip().toUpperCase(); + String val = trimmed.substring(eq + 1).strip(); + byte[] oidBytes; + byte[] valueBytes; + switch (key) { + case "CN": + oidBytes = OID_BYTES_CN; + valueBytes = tlv(TAG_UTF8_STRING, val.getBytes(StandardCharsets.UTF_8)); + break; + case "C": + oidBytes = OID_BYTES_C; + // countryName uses PrintableString; ISO 3166-1 alpha-2 codes are ASCII + valueBytes = tlv(TAG_PRINTABLE_STRING, val.getBytes(StandardCharsets.US_ASCII)); + break; + case "O": + oidBytes = OID_BYTES_O; + valueBytes = tlv(TAG_UTF8_STRING, val.getBytes(StandardCharsets.UTF_8)); + break; + case "OU": + oidBytes = OID_BYTES_OU; + valueBytes = tlv(TAG_UTF8_STRING, val.getBytes(StandardCharsets.UTF_8)); + break; + default: + continue; // unsupported attribute — skip + } + byte[] rdn = set(sequence(cat(oidBytes, valueBytes))); + rdns.write(rdn, 0, rdn.length); + } + if (rdns.size() == 0) { + // fallback: treat the whole string as a CN value + byte[] cnValue = tlv(TAG_UTF8_STRING, dn.getBytes(StandardCharsets.UTF_8)); + byte[] rdn = set(sequence(cat(OID_BYTES_CN, cnValue))); + rdns.write(rdn, 0, rdn.length); + } + return sequence(rdns.toByteArray()); + } + + // ------------------------------------------------------------------------- + // DER / ASN.1 primitives + // ------------------------------------------------------------------------- + + /** + * Encodes the provided content as an ASN.1 DER SEQUENCE. + * + *

A SEQUENCE in ASN.1 represents an ordered collection of elements + *

The DER tag for a SEQUENCE is 0x30.

+ *
+     * AttributeTypeAndValue ::= SEQUENCE {
+     *   type   OBJECT IDENTIFIER,
+     *   value  DirectoryString
+     * }
+     * 
+ * + *

Thus, for the DN CN=Test, the inner attribute pair is encoded as:

+ * + *
+     * 30 ...                SEQUENCE (AttributeTypeAndValue)
+     *   06 03 55 04 03      OID 2.5.4.3 (commonName)
+     *   0C 04 54 65 73 74   UTF8String "Test"
+     * 
+ * + * @param content the already‑encoded DER content to wrap in a SEQUENCE + * @return the DER‑encoded SEQUENCE (tag 0x30 + length + content) + */ + private static byte[] sequence(byte[] content) { + return tlv(TAG_SEQUENCE, content); + } + + /** + * Encodes the provided content as an ASN.1 DER SET value. + * + *

In ASN.1, a SET represents an unordered collection of elements. Although the + * abstract syntax does not impose ordering, DER requires all elements inside a SET + * to be sorted by their encoded byte values to ensure canonical form.

+ * + *

The DER tag for a SET is 0x31.

+ * + *

Use in X.509:
+ * Within an X.509 Distinguished Name (DN), each RelativeDistinguishedName (RDN) + * is encoded as a SET containing one or more AttributeTypeAndValue structures. + * A DN therefore follows the structure:

+ * + *
+     * Name ::= SEQUENCE OF
+     *            SET OF
+     *              SEQUENCE {
+     *                type   OBJECT IDENTIFIER,   -- e.g., 2.5.4.3 (commonName)
+     *                value  DirectoryString      -- e.g., UTF8String "Test"
+     *              }
+     * 
+ * @param content the already‑encoded DER content to wrap in a SET + * @return the DER-encoded SET (tag 0x31 + length + content) + */ + private static byte[] set(byte[] content) { + return tlv(TAG_SET, content); + } + + private static byte[] integer(BigInteger value) { + // toByteArray() produces two's-complement big-endian; positive integers may + // have a leading 0x00 byte if the MSB would otherwise be set — that is correct + // DER INTEGER encoding for a non-negative number. + return tlv(TAG_INTEGER, value.toByteArray()); + } + + private static byte[] bitString(byte[] value) { + return tlv(TAG_BIT_STRING, cat(new byte[]{0x00}, value)); // 0x00 = zero unused bits + } + + // UTCTime covers 2000–2049 (yy < 50 → 20yy). Sufficient for short-lived test certs. + private static final DateTimeFormatter UTC_TIME_FMT = + DateTimeFormatter.ofPattern("yyMMddHHmmss'Z'").withZone(ZoneOffset.UTC); + + private static byte[] utcTime(Instant instant) { + return tlv(TAG_UTC_TIME, UTC_TIME_FMT.format(instant).getBytes(StandardCharsets.US_ASCII)); + } + + /** + * Encodes a DER TLV (Tag–Length–Value) triplet. + * Lengths up to 65535 bytes are supported; that is sufficient for all key types + * used in practice. + */ + private static byte[] tlv(int tag, byte[] value) { + int len = value.length; + byte[] lenBytes; + if (len < 128) { + lenBytes = new byte[]{(byte) len}; + } else if (len < 256) { + lenBytes = new byte[]{(byte) 0x81, (byte) len}; + } else { + lenBytes = new byte[]{(byte) 0x82, (byte) (len >> 8), (byte) (len & 0xFF)}; + } + byte[] out = new byte[1 + lenBytes.length + len]; + out[0] = (byte) tag; + System.arraycopy(lenBytes, 0, out, 1, lenBytes.length); + System.arraycopy(value, 0, out, 1 + lenBytes.length, len); + return out; + } + + /** + * Concatenates byte arrays. + */ + private static byte[] cat(byte[]... parts) { + int total = 0; + for (byte[] p : parts) { + total += p.length; + } + byte[] buf = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, buf, pos, p.length); + pos += p.length; + } + return buf; + } + + /** + * Encode oid as certificate algorithm identifier. + * @param oid + * @return + */ + public static byte[] encodeAlgorithmIdentifier(String oid) { + // RFC 8410 §6: all OIDs under arc 1.3.101 (X25519, X448, Ed25519, Ed448) MUST omit parameters. + // draft-ietf-lamps-dilithium-certificates §5.1: ML-DSA OIDs MUST omit parameters. + // RFC 4055 §3.2: RSA signature algorithms MUST include a NULL parameters element. + byte[] params = NO_PARAMS_ALGORITHMS.contains(oid) ? new byte[0] : DER_NULL; + return sequence(cat(encodeOid(oid), params)); + } + + /** + * Endodes all number values to ASN.1/DER encoded bytearray + * @param oid - the value + * @return encoded byte array + */ + public static byte[] encodeOid(String oid) { + String[] parts = oid.split("\\."); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(40 * Integer.parseInt(parts[0]) + Integer.parseInt(parts[1])); + for (int i = 2; i < parts.length; i++) { + byte[] arc = encodeBase128(Long.parseLong(parts[i])); + body.write(arc, 0, arc.length); + } + return tlv(TAG_OID, body.toByteArray()); + } + + /** + * It encodes a non-negative integer using base-128 (variable-length) encoding, which is the standard + * way ASN.1/DER encodes OID arc values + * @param value the long value + * @return ASN.1/DER encoded value + */ + private static byte[] encodeBase128(long value) { + byte[] stack = new byte[10]; + int count = 0; + do { + stack[count++] = (byte) (value & 0x7F); + value >>= 7; + } while (value > 0); + byte[] result = new byte[count]; + for (int i = 0; i < count; i++) { + result[i] = (byte) (stack[count - 1 - i] | (i < count - 1 ? 0x80 : 0x00)); + } + return result; + } +} From 43b53177aa7e9d4de6b8066399585cfe01b376b5 Mon Sep 17 00:00:00 2001 From: arpansharma Date: Fri, 21 Aug 2026 12:55:12 -0500 Subject: [PATCH 2/6] Parameterize the negative signature tests across all ML-DSA parameter 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. --- .../crypto/dsig/XMLSignatureMLDSATest.java | 25 +++++++++---- .../signature/StaxMLDSASignatureTest.java | 37 ++++++++++++------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureMLDSATest.java b/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureMLDSATest.java index 46fed4bbb..29b3f0bf6 100644 --- a/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureMLDSATest.java +++ b/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureMLDSATest.java @@ -47,7 +47,6 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.w3c.dom.Document; @@ -131,10 +130,15 @@ void testMLDSASignAndVerify(String signatureAlgorithmURI, String alias) throws E assertValidSignatureWithJcpApi(signedXml, false); } - @Test - void testMLDSATamperedSignatureRejected() throws Exception { + @ParameterizedTest + @CsvSource({ + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_44 + ",ml-dsa-44", + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65 + ",ml-dsa-65", + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_87 + ",ml-dsa-87", + }) + void testMLDSATamperedSignatureRejected(String signatureAlgorithmURI, String alias) throws Exception { Assumptions.assumeTrue(mlDsaAvailable, "ML-DSA requires BouncyCastle 1.81+"); - byte[] signedXml = doSignWithJcpApi(XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65, "ml-dsa-65", false); + byte[] signedXml = doSignWithJcpApi(signatureAlgorithmURI, alias, false); byte[] tamperedXml = flipByteInSignatureValue(signedXml); @@ -142,12 +146,17 @@ void testMLDSATamperedSignatureRejected() throws Exception { Assertions.assertFalse(coreValidity, "A tampered SignatureValue must not validate"); } - @Test - void testMLDSAWrongPublicKeyRejected() throws Exception { + @ParameterizedTest + @CsvSource({ + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_44 + ",ml-dsa-44", + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65 + ",ml-dsa-65", + XMLSignature.ALGO_ID_SIGNATURE_MLDSA_87 + ",ml-dsa-87", + }) + void testMLDSAWrongPublicKeyRejected(String signatureAlgorithmURI, String alias) throws Exception { Assumptions.assumeTrue(mlDsaAvailable, "ML-DSA requires BouncyCastle 1.81+"); - byte[] signedXml = doSignWithJcpApi(XMLSignature.ALGO_ID_SIGNATURE_MLDSA_65, "ml-dsa-65", false); + byte[] signedXml = doSignWithJcpApi(signatureAlgorithmURI, alias, false); - KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-DSA-65", "BC"); + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alias.toUpperCase(), "BC"); PublicKey wrongPublicKey = kpg.generateKeyPair().getPublic(); KeySelector wrongKeySelector = new KeySelector() { diff --git a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java index 3758a16a7..0fd45b669 100644 --- a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java @@ -41,7 +41,6 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.w3c.dom.Document; @@ -109,28 +108,38 @@ void testMLDSASign(String sigAlgorithm, String jcaAlgorithm) throws Exception { verifyUsingDOM(document, kp.getPublic(), properties.getSignatureSecureParts()); } - @Test - void testMLDSAStaxTamperedSignatureRejected() throws Exception { - Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey("ML-DSA-65"), + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testMLDSAStaxTamperedSignatureRejected(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), "ML-DSA requires BouncyCastle 1.81+"); - Document document = signWithMLDSA65(); + Document document = signWith(sigAlgorithm, jcaAlgorithm); Element sigElement = tamperSignatureValue(document); XMLSignature signature = new XMLSignature(sigElement, ""); - boolean coreValidity = signature.checkSignatureValue(keyPairs.get("ML-DSA-65").getPublic()); + boolean coreValidity = signature.checkSignatureValue(keyPairs.get(jcaAlgorithm).getPublic()); Assertions.assertFalse(coreValidity, "A tampered SignatureValue must not validate"); } - @Test - void testMLDSAStaxWrongPublicKeyRejected() throws Exception { - Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey("ML-DSA-65"), + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testMLDSAStaxWrongPublicKeyRejected(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), "ML-DSA requires BouncyCastle 1.81+"); - Document document = signWithMLDSA65(); + Document document = signWith(sigAlgorithm, jcaAlgorithm); Element sigElement = (Element) document.getElementsByTagNameNS(Constants.SignatureSpecNS, "Signature").item(0); - KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-DSA-65", "BC"); + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "BC"); PublicKey wrongPublicKey = kpg.generateKeyPair().getPublic(); XMLSignature signature = new XMLSignature(sigElement, ""); @@ -138,15 +147,15 @@ void testMLDSAStaxWrongPublicKeyRejected() throws Exception { Assertions.assertFalse(coreValidity, "Verification against the wrong public key must not validate"); } - private Document signWithMLDSA65() throws Exception { + private Document signWith(String sigAlgorithm, String jcaAlgorithm) throws Exception { XMLSecurityProperties properties = new XMLSecurityProperties(); List actions = new ArrayList<>(); actions.add(XMLSecurityConstants.SIGNATURE); properties.setActions(actions); properties.setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue); - properties.setSignatureAlgorithm("http://www.w3.org/tbd#ml-dsa-65"); + properties.setSignatureAlgorithm(sigAlgorithm); - KeyPair kp = keyPairs.get("ML-DSA-65"); + KeyPair kp = keyPairs.get(jcaAlgorithm); properties.setSignatureKey(kp.getPrivate()); properties.setSignatureVerificationKey(kp.getPublic()); From 58ef7d8782a9c4d2d629475fae18840f92cc4996 Mon Sep 17 00:00:00 2001 From: arpansharma Date: Fri, 21 Aug 2026 16:40:45 -0500 Subject: [PATCH 3/6] Emit dsig11:DEREncodedKeyValue for keys without a structured KeyValue form StAX signing with the KeyValue key identifier dispatched on the public key algorithm with branches for RSA, DSA and EC only. For any other key type (ML-DSA, EdDSA) it fell through and emitted an empty , which the inbound processor then rejects at schema validation before reaching signature verification. Emit a dsig11:DEREncodedKeyValue holding the DER SubjectPublicKeyInfo for those key types, which is schema-valid inside dsig:KeyValue via its ##other wildcard and mirrors the DOM DEREncodedKeyValue support. Adds a parameterized test across ML-DSA-44/65/87 asserting the KeyValue carries a DEREncodedKeyValue that round-trips to the signer's public key and verifies the signature. Inbound extraction of a public key from DEREncodedKeyValue is not added here; the StAX inbound path has no DEREncodedKeyValue support for any key type yet. --- .../stax/ext/XMLSecurityConstants.java | 1 + .../security/stax/ext/XMLSecurityUtils.java | 12 ++ .../stax/signature/StaxMLDSAKeyValueTest.java | 146 ++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueTest.java diff --git a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java index 3368c97a3..10b96bfee 100644 --- a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java +++ b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityConstants.java @@ -223,6 +223,7 @@ public enum DIRECTION { public static final QName TAG_dsig11_ECParameters = new QName(NS_DSIG11, "ECParameters", PREFIX_DSIG11); public static final QName TAG_dsig11_NamedCurve = new QName(NS_DSIG11, "NamedCurve", PREFIX_DSIG11); public static final QName TAG_dsig11_PublicKey = new QName(NS_DSIG11, "PublicKey", PREFIX_DSIG11); + public static final QName TAG_dsig11_DEREncodedKeyValue = new QName(NS_DSIG11, "DEREncodedKeyValue", PREFIX_DSIG11); public static final String NS_C14N_EXCL = "http://www.w3.org/2001/10/xml-exc-c14n#"; public static final String NS_XMLDSIG_FILTER2 = "http://www.w3.org/2002/06/xmldsig-filter2"; diff --git a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityUtils.java b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityUtils.java index ec4a9f6ae..3c0aafd80 100644 --- a/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityUtils.java +++ b/src/main/java/org/apache/xml/security/stax/ext/XMLSecurityUtils.java @@ -239,6 +239,18 @@ public static void createKeyValueTokenStructure(AbstractOutputProcessor abstract abstractOutputProcessor.createCharactersAndOutputAsEvent(outputProcessorChain, XMLUtils.encodeToString(ECDSAUtils.encodePoint(ecPublicKey.getW(), ecPublicKey.getParams().getCurve()))); abstractOutputProcessor.createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig11_PublicKey); abstractOutputProcessor.createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig11_ECKeyValue); + } else { + // Key types without a structured KeyValue form (e.g. ML-DSA, EdDSA) are carried as a + // dsig11:DEREncodedKeyValue holding the DER SubjectPublicKeyInfo, which is schema-valid + // inside dsig:KeyValue via its ##other wildcard. Without this, such a key produced an + // empty that the inbound processor rejects at schema validation. + byte[] encoded = publicKey.getEncoded(); + if (encoded == null) { + throw new XMLSecurityException("stax.unsupportedKeyValue"); + } + abstractOutputProcessor.createStartElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig11_DEREncodedKeyValue, false, null); + abstractOutputProcessor.createCharactersAndOutputAsEvent(outputProcessorChain, XMLUtils.encodeToString(encoded)); + abstractOutputProcessor.createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig11_DEREncodedKeyValue); } abstractOutputProcessor.createEndElementAndOutputAsEvent(outputProcessorChain, XMLSecurityConstants.TAG_dsig_KeyValue); diff --git a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueTest.java b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueTest.java new file mode 100644 index 000000000..b1e1a9396 --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueTest.java @@ -0,0 +1,146 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.test.stax.signature; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; + +import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.stax.ext.SecurePart; +import org.apache.xml.security.stax.ext.XMLSecurityConstants; +import org.apache.xml.security.stax.ext.XMLSecurityProperties; +import org.apache.xml.security.stax.securityToken.SecurityTokenConstants; +import org.apache.xml.security.utils.Constants; +import org.apache.xml.security.utils.XMLUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** + * Tests that ML-DSA StAX signing with the {@code KeyValue} key identifier emits a + * schema-valid {@code dsig11:DEREncodedKeyValue} carrying the DER SubjectPublicKeyInfo, + * rather than an empty {@code }. ML-DSA (and other key types without a + * structured KeyValue form) have no RSA/DSA/EC-style KeyValue child, so the DER encoding + * is the schema-valid way to carry the key inline. + */ +class StaxMLDSAKeyValueTest extends AbstractSignatureCreationTest { + + private static final String NS_DSIG11 = "http://www.w3.org/2009/xmldsig11#"; + private static final Map keyPairs = new HashMap<>(); + + @BeforeAll + static void generateKeys() throws Exception { + if (!isBcInstalled()) { + return; + } + try { + for (String alg : new String[]{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"}) { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg, "BC"); + keyPairs.put(alg, kpg.generateKeyPair()); + } + } catch (Exception e) { + // ML-DSA not available with this BC version + } + } + + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testKeyValueEmitsDerEncodedKeyValue(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), + "ML-DSA requires BouncyCastle 1.81+"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + Document document = signWithKeyValue(sigAlgorithm, kp); + + // The KeyValue must not be empty (the pre-fix behavior) and must contain a DEREncodedKeyValue. + NodeList keyValues = document.getElementsByTagNameNS(Constants.SignatureSpecNS, "KeyValue"); + Assertions.assertEquals(1, keyValues.getLength(), "Expected exactly one KeyValue"); + Element keyValue = (Element) keyValues.item(0); + + NodeList der = keyValue.getElementsByTagNameNS(NS_DSIG11, "DEREncodedKeyValue"); + Assertions.assertEquals(1, der.getLength(), + "KeyValue must carry a dsig11:DEREncodedKeyValue for ML-DSA, not be empty"); + + // The DER content must decode back to the signer's public key. + byte[] encoded = Base64.getMimeDecoder().decode(der.item(0).getTextContent()); + KeyFactory kf = KeyFactory.getInstance(jcaAlgorithm, "BC"); + PublicKey recovered = kf.generatePublic(new X509EncodedKeySpec(encoded)); + Assertions.assertArrayEquals(kp.getPublic().getEncoded(), recovered.getEncoded(), + "DEREncodedKeyValue must round-trip to the signer's public key"); + + // The recovered key must verify the signature, proving the embedded key is usable. + // Register the signed element's Id so the same-document Reference resolves on the re-parsed DOM. + NodeList signed = document.getElementsByTagNameNS("urn:example:po", "PaymentInfo"); + for (int i = 0; i < signed.getLength(); i++) { + Element e = (Element) signed.item(i); + if (e.hasAttributeNS(null, "Id")) { + e.setIdAttributeNS(null, "Id", true); + } + } + Element sigElement = (Element) document.getElementsByTagNameNS( + Constants.SignatureSpecNS, "Signature").item(0); + XMLSignature signature = new XMLSignature(sigElement, ""); + Assertions.assertTrue(signature.checkSignatureValue(recovered), + "Signature must verify under the key recovered from DEREncodedKeyValue"); + } + + private Document signWithKeyValue(String sigAlgorithm, KeyPair kp) throws Exception { + XMLSecurityProperties properties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.SIGNATURE); + properties.setActions(actions); + properties.setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue); + properties.setSignatureAlgorithm(sigAlgorithm); + properties.setSignatureKey(kp.getPrivate()); + properties.setSignatureVerificationKey(kp.getPublic()); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), + SecurePart.Modifier.Content, + new String[]{"http://www.w3.org/2001/10/xml-exc-c14n#"}, + "http://www.w3.org/2001/04/xmlenc#sha256"); + properties.addSignaturePart(securePart); + + byte[] output = process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", properties, null); + try (InputStream is = new ByteArrayInputStream(output)) { + return XMLUtils.read(is, false); + } + } +} From 5898249237b100c6ba9629ced36b9dd1cb3e373d Mon Sep 17 00:00:00 2001 From: arpansharma Date: Sat, 22 Aug 2026 13:33:12 -0500 Subject: [PATCH 4/6] Resolve dsig11:DEREncodedKeyValue on the StAX inbound path The StAX inbound processor resolved a KeyValue only through its RSA, DSA and EC forms, so a dsig11:DEREncodedKeyValue (the KeyValue form for key types without a structured element, such as ML-DSA) failed at key resolution with "No or unsupported key in KeyValue" even though the outbound side now emits it. Add DEREncodedKeyValueSecurityToken, which rebuilds the public key from the DER SubjectPublicKeyInfo by trying the same key types as the DOM DEREncodedKeyValue, and resolve it from both placements: nested inside ds:KeyValue (as emitted) and as a direct ds:KeyInfo child (the XML Signature 1.1 placement). With this a StAX-signed ML-DSA document verifies through the StAX inbound path with no out-of-band key. Adds StaxMLDSAKeyValueInboundTest covering both placements (asserting the resolved key is the signer's) and tampered signature rejection, parameterized across ML-DSA-44/65/87; all nine cases fail without the factory changes. --- .../DEREncodedKeyValueSecurityToken.java | 95 +++++++ .../SecurityTokenFactoryImpl.java | 20 ++ .../StaxMLDSAKeyValueInboundTest.java | 264 ++++++++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java create mode 100644 src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java diff --git a/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java b/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java new file mode 100644 index 000000000..221b75cc2 --- /dev/null +++ b/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.stax.impl.securityToken; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import org.apache.xml.security.binding.xmldsig11.DEREncodedKeyValueType; +import org.apache.xml.security.exceptions.XMLSecurityException; +import org.apache.xml.security.stax.ext.InboundSecurityContext; +import org.apache.xml.security.stax.impl.util.IDGenerator; +import org.apache.xml.security.stax.securityToken.SecurityTokenConstants; + +/** + * Inbound security token for a {@code dsig11:DEREncodedKeyValue}: the DER-encoded + * SubjectPublicKeyInfo of a public key, which is the KeyValue form for key types that have no + * structured KeyValue element (ML-DSA, EdDSA, ...). The StAX counterpart of the DOM + * {@code DEREncodedKeyValue} support; the public key is rebuilt lazily from the encoding by + * trying each supported key type's {@link KeyFactory}. + */ +public class DEREncodedKeyValueSecurityToken extends AbstractInboundSecurityToken { + + // Same key types as the DOM DEREncodedKeyValue.supportedKeyTypes + private static final String[] SUPPORTED_KEY_TYPES = { "RSA", "DSA", "EC", + "DiffieHellman", "DH", "XDH", "X25519", "X448", + "EdDSA", "Ed25519", "Ed448", + "ML-DSA-44", "ML-DSA-65", "ML-DSA-87", + "RSASSA-PSS"}; + + private final byte[] encodedKey; + + public DEREncodedKeyValueSecurityToken(DEREncodedKeyValueType derEncodedKeyValueType, + InboundSecurityContext inboundSecurityContext) + throws XMLSecurityException { + super(inboundSecurityContext, IDGenerator.generateID(null), SecurityTokenConstants.KeyIdentifier_KeyValue, true); + + byte[] value = derEncodedKeyValueType.getValue(); + if (value == null || value.length == 0) { + throw new XMLSecurityException("stax.unsupportedKeyValue"); + } + this.encodedKey = value.clone(); + } + + private PublicKey buildPublicKey() throws XMLSecurityException { + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedKey); + for (String keyType : SUPPORTED_KEY_TYPES) { + try { + PublicKey publicKey = KeyFactory.getInstance(keyType).generatePublic(keySpec); + if (publicKey != null) { + return publicKey; + } + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { //NOPMD + // Not this key type; try the next one + } + } + throw new XMLSecurityException("stax.unsupportedKeyValue"); + } + + @Override + public PublicKey getPublicKey() throws XMLSecurityException { + if (super.getPublicKey() == null) { + setPublicKey(buildPublicKey()); + } + return super.getPublicKey(); + } + + @Override + public boolean isAsymmetric() { + return true; + } + + @Override + public SecurityTokenConstants.TokenType getTokenType() { + return SecurityTokenConstants.KeyValueToken; + } +} diff --git a/src/main/java/org/apache/xml/security/stax/impl/securityToken/SecurityTokenFactoryImpl.java b/src/main/java/org/apache/xml/security/stax/impl/securityToken/SecurityTokenFactoryImpl.java index 5378e1975..d5d76b299 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/securityToken/SecurityTokenFactoryImpl.java +++ b/src/main/java/org/apache/xml/security/stax/impl/securityToken/SecurityTokenFactoryImpl.java @@ -33,6 +33,7 @@ import org.apache.xml.security.binding.xmldsig.RSAKeyValueType; import org.apache.xml.security.binding.xmldsig.X509DataType; import org.apache.xml.security.binding.xmldsig.X509IssuerSerialType; +import org.apache.xml.security.binding.xmldsig11.DEREncodedKeyValueType; import org.apache.xml.security.binding.xmldsig11.ECKeyValueType; import org.apache.xml.security.exceptions.XMLSecurityException; import org.apache.xml.security.stax.ext.InboundSecurityContext; @@ -76,6 +77,17 @@ public InboundSecurityToken getSecurityToken(KeyInfoType keyInfoType, return getSecurityToken(keyValueType, securityProperties, inboundSecurityContext, keyUsage); } + // DEREncodedKeyValue as a direct KeyInfo child, the XML Signature 1.1 placement + // (the nested-in-KeyValue placement is handled in the KeyValue branch above) + final DEREncodedKeyValueType derEncodedKeyValueType = XMLSecurityUtils.getQNameType( + keyInfoType.getContent(), XMLSecurityConstants.TAG_dsig11_DEREncodedKeyValue); + if (derEncodedKeyValueType != null) { + DEREncodedKeyValueSecurityToken token = + new DEREncodedKeyValueSecurityToken(derEncodedKeyValueType, inboundSecurityContext); + setTokenKey(securityProperties, keyUsage, token); + return token; + } + // KeyName final String keyName = XMLSecurityUtils.getQNameType(keyInfoType.getContent(), XMLSecurityConstants.TAG_dsig_KeyName); @@ -165,6 +177,14 @@ private static InboundSecurityToken getSecurityToken(KeyValueType keyValueType, setTokenKey(securityProperties, keyUsage, token); return token; } + final DEREncodedKeyValueType derEncodedKeyValueType = + XMLSecurityUtils.getQNameType(keyValueType.getContent(), XMLSecurityConstants.TAG_dsig11_DEREncodedKeyValue); + if (derEncodedKeyValueType != null) { + DEREncodedKeyValueSecurityToken token = + new DEREncodedKeyValueSecurityToken(derEncodedKeyValueType, inboundSecurityContext); + setTokenKey(securityProperties, keyUsage, token); + return token; + } throw new XMLSecurityException("stax.unsupportedKeyValue"); } diff --git a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java new file mode 100644 index 000000000..9abe7116b --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java @@ -0,0 +1,264 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.test.stax.signature; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +import org.apache.xml.security.stax.ext.InboundXMLSec; +import org.apache.xml.security.stax.ext.SecurePart; +import org.apache.xml.security.stax.ext.XMLSec; +import org.apache.xml.security.stax.ext.XMLSecurityConstants; +import org.apache.xml.security.stax.ext.XMLSecurityProperties; +import org.apache.xml.security.stax.securityEvent.KeyValueTokenSecurityEvent; +import org.apache.xml.security.stax.securityEvent.SecurityEvent; +import org.apache.xml.security.stax.securityToken.SecurityTokenConstants; +import org.apache.xml.security.test.stax.utils.StAX2DOM; +import org.apache.xml.security.test.stax.utils.XMLSecEventAllocator; +import org.apache.xml.security.utils.Constants; +import org.apache.xml.security.utils.XMLUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.w3c.dom.Text; + +/** + * StAX inbound verification of ML-DSA signatures whose KeyInfo carries the public key as a + * {@code dsig11:DEREncodedKeyValue} (the KeyValue form emitted for key types without a + * structured KeyValue element). No verification key is supplied out of band: the inbound + * processor must resolve the key from the document itself, then verify with it. + */ +class StaxMLDSAKeyValueInboundTest extends AbstractSignatureCreationTest { + + private static final Map keyPairs = new HashMap<>(); + + @BeforeAll + static void generateKeys() throws Exception { + if (!isBcInstalled()) { + return; + } + try { + for (String alg : new String[]{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"}) { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg, "BC"); + keyPairs.put(alg, kpg.generateKeyPair()); + } + } catch (Exception e) { + // ML-DSA not available with this BC version + } + } + + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testInboundVerifiesWithKeyFromDerEncodedKeyValue(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), + "ML-DSA requires BouncyCastle 1.81+"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + byte[] signed = signWithKeyValue(sigAlgorithm, kp); + + List events = new ArrayList<>(); + Document verified = verifyInbound(signed, events); + Assertions.assertNotNull(verified.getDocumentElement(), "Inbound processing must yield a document"); + + // The key the inbound processor verified with must be the signer's, recovered from the + // document's DEREncodedKeyValue (no key was supplied out of band). + PublicKey resolved = null; + for (SecurityEvent event : events) { + if (event instanceof KeyValueTokenSecurityEvent) { + resolved = ((KeyValueTokenSecurityEvent) event).getSecurityToken().getPublicKey(); + } + } + Assertions.assertNotNull(resolved, "Expected a KeyValueTokenSecurityEvent carrying the resolved key"); + Assertions.assertArrayEquals(kp.getPublic().getEncoded(), resolved.getEncoded(), + "Key resolved from DEREncodedKeyValue must be the signer's public key"); + } + + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testInboundTamperedSignatureRejected(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), + "ML-DSA requires BouncyCastle 1.81+"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + byte[] tampered = tamperSignatureValue(signWithKeyValue(sigAlgorithm, kp)); + + // The key still resolves from the document; the rejection must come from signature + // validation itself, not from a failure to resolve the key. + XMLStreamException ex = Assertions.assertThrows(XMLStreamException.class, + () -> verifyInbound(tampered, new ArrayList<>())); + String chain = messageChain(ex); + Assertions.assertTrue(chain.contains("INVALID signature"), + "Expected a core-validation failure, got: " + chain); + } + + private static String messageChain(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null; c = c.getCause()) { + sb.append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()).append(" | "); + } + return sb.toString(); + } + + /** + * XML Signature 1.1 defines {@code dsig11:DEREncodedKeyValue} as a direct child of + * {@code ds:KeyInfo}; the nested-in-KeyValue placement is what this library emits. A document + * from another implementation may use the canonical placement, so the inbound side must + * resolve the key from there as well. + */ + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testInboundVerifiesWithDerEncodedKeyValueAsKeyInfoChild(String sigAlgorithm, String jcaAlgorithm) + throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), + "ML-DSA requires BouncyCastle 1.81+"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + byte[] signed = moveDerEncodedKeyValueToKeyInfo(signWithKeyValue(sigAlgorithm, kp)); + + List events = new ArrayList<>(); + Document verified = verifyInbound(signed, events); + Assertions.assertNotNull(verified.getDocumentElement(), "Inbound processing must yield a document"); + + PublicKey resolved = null; + for (SecurityEvent event : events) { + if (event instanceof KeyValueTokenSecurityEvent) { + resolved = ((KeyValueTokenSecurityEvent) event).getSecurityToken().getPublicKey(); + } + } + Assertions.assertNotNull(resolved, "Expected a KeyValueTokenSecurityEvent carrying the resolved key"); + Assertions.assertArrayEquals(kp.getPublic().getEncoded(), resolved.getEncoded(), + "Key resolved from a KeyInfo-level DEREncodedKeyValue must be the signer's public key"); + } + + /** + * Re-parents the emitted {@code dsig11:DEREncodedKeyValue} from inside {@code ds:KeyValue} to + * be a direct child of {@code ds:KeyInfo} (removing the now-empty KeyValue), giving the + * canonical XML Signature 1.1 layout. KeyInfo is outside the signed content, so the + * signature stays valid. + */ + private byte[] moveDerEncodedKeyValueToKeyInfo(byte[] signed) throws Exception { + Document document; + try (InputStream is = new ByteArrayInputStream(signed)) { + document = XMLUtils.read(is, false); + } + Element keyInfo = (Element) document.getElementsByTagNameNS(Constants.SignatureSpecNS, "KeyInfo").item(0); + Element keyValue = (Element) keyInfo.getElementsByTagNameNS(Constants.SignatureSpecNS, "KeyValue").item(0); + Element der = (Element) keyValue.getElementsByTagNameNS( + "http://www.w3.org/2009/xmldsig11#", "DEREncodedKeyValue").item(0); + Assertions.assertNotNull(der, "Expected a DEREncodedKeyValue inside KeyValue to re-parent"); + keyValue.removeChild(der); + keyInfo.replaceChild(der, keyValue); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + javax.xml.transform.TransformerFactory.newInstance().newTransformer().transform( + new javax.xml.transform.dom.DOMSource(document), + new javax.xml.transform.stream.StreamResult(bos)); + return bos.toByteArray(); + } + + private Document verifyInbound(byte[] signed, List events) throws Exception { + XMLSecurityProperties properties = new XMLSecurityProperties(); + // deliberately no setSignatureVerificationKey(...) + InboundXMLSec inboundXMLSec = XMLSec.getInboundWSSec(properties); + XMLInputFactory xmlInputFactory = XMLInputFactory.newInstance(); + xmlInputFactory.setEventAllocator(new XMLSecEventAllocator()); + XMLStreamReader xmlStreamReader = xmlInputFactory.createXMLStreamReader(new ByteArrayInputStream(signed)); + XMLStreamReader securityStreamReader = + inboundXMLSec.processInMessage(xmlStreamReader, null, events::add); + return StAX2DOM.readDoc(securityStreamReader); + } + + private byte[] signWithKeyValue(String sigAlgorithm, KeyPair kp) throws Exception { + XMLSecurityProperties properties = new XMLSecurityProperties(); + List actions = new ArrayList<>(); + actions.add(XMLSecurityConstants.SIGNATURE); + properties.setActions(actions); + properties.setSignatureKeyIdentifier(SecurityTokenConstants.KeyIdentifier_KeyValue); + properties.setSignatureAlgorithm(sigAlgorithm); + properties.setSignatureKey(kp.getPrivate()); + properties.setSignatureVerificationKey(kp.getPublic()); + + SecurePart securePart = new SecurePart( + new QName("urn:example:po", "PaymentInfo"), + SecurePart.Modifier.Content, + new String[]{"http://www.w3.org/2001/10/xml-exc-c14n#"}, + "http://www.w3.org/2001/04/xmlenc#sha256"); + properties.addSignaturePart(securePart); + + return process("ie/baltimore/merlin-examples/merlin-xmlenc-five/plaintext.xml", properties, null); + } + + /** Flips one byte of the SignatureValue and re-serializes. */ + private byte[] tamperSignatureValue(byte[] signed) throws Exception { + Document document; + try (InputStream is = new ByteArrayInputStream(signed)) { + document = XMLUtils.read(is, false); + } + NodeList sigValues = document.getElementsByTagNameNS(Constants.SignatureSpecNS, "SignatureValue"); + Assertions.assertEquals(1, sigValues.getLength(), "Expected exactly one SignatureValue element"); + Element sigValueElement = (Element) sigValues.item(0); + + byte[] sigBytes = Base64.getMimeDecoder().decode(sigValueElement.getTextContent()); + sigBytes[sigBytes.length / 2] ^= (byte) 0xFF; + + NodeList children = sigValueElement.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + sigValueElement.removeChild(children.item(i)); + } + Text newText = document.createTextNode(Base64.getEncoder().encodeToString(sigBytes)); + sigValueElement.appendChild(newText); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + javax.xml.transform.TransformerFactory.newInstance().newTransformer().transform( + new javax.xml.transform.dom.DOMSource(document), + new javax.xml.transform.stream.StreamResult(bos)); + return bos.toByteArray(); + } +} From dcd65cdb49d2e139bae23578aa6542dadf61b655 Mon Sep 17 00:00:00 2001 From: arpansharma Date: Tue, 25 Aug 2026 14:26:55 -0500 Subject: [PATCH 5/6] Reject malformed DEREncodedKeyValue content cleanly buildPublicKey() caught only NoSuchAlgorithmException and InvalidKeySpecException, but some providers (BouncyCastle's XDH/EdDSA KeyFactorySpi) throw an unchecked ArrayIndexOutOfBoundsException for malformed or short input rather than InvalidKeySpecException. Because the DEREncodedKeyValue content is untrusted, attacker-controlled inbound data, that exception propagated out of processInMessage instead of being rejected cleanly. Also catch RuntimeException in the key-type loop so a malformed encoding falls through to a clean stax.unsupportedKeyValue rejection, and add a garbage-content case to StaxMLDSAKeyValueInboundTest (parameterized across ML-DSA-44/65/87) that fails without this change. --- .../DEREncodedKeyValueSecurityToken.java | 8 ++- .../StaxMLDSAKeyValueInboundTest.java | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java b/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java index 221b75cc2..4e8042c05 100644 --- a/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java +++ b/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java @@ -68,8 +68,12 @@ private PublicKey buildPublicKey() throws XMLSecurityException { if (publicKey != null) { return publicKey; } - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { //NOPMD - // Not this key type; try the next one + } catch (NoSuchAlgorithmException | InvalidKeySpecException | RuntimeException e) { //NOPMD + // Not this key type; try the next one. Some providers (e.g. BouncyCastle's + // XDH/EdDSA KeyFactorySpi) throw an unchecked exception such as + // ArrayIndexOutOfBoundsException instead of InvalidKeySpecException for + // malformed or short input, which must not propagate since encodedKey here is + // untrusted, attacker-controlled inbound content. } } throw new XMLSecurityException("stax.unsupportedKeyValue"); diff --git a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java index 9abe7116b..5b93e604f 100644 --- a/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java @@ -203,6 +203,55 @@ private byte[] moveDerEncodedKeyValueToKeyInfo(byte[] signed) throws Exception { return bos.toByteArray(); } + /** + * A DEREncodedKeyValue whose content is garbage (decodes to no valid SubjectPublicKeyInfo) + * must be rejected cleanly at key resolution, not crash the pipeline with an uncaught + * RuntimeException. Some providers throw an unchecked exception (e.g. BouncyCastle's + * XDH/EdDSA KeyFactorySpi throws ArrayIndexOutOfBoundsException) for malformed input, and + * inbound KeyInfo content is attacker-controlled. + */ + @ParameterizedTest + @CsvSource({ + "http://www.w3.org/tbd#ml-dsa-44,ML-DSA-44", + "http://www.w3.org/tbd#ml-dsa-65,ML-DSA-65", + "http://www.w3.org/tbd#ml-dsa-87,ML-DSA-87" + }) + void testInboundGarbageDerContentRejectedCleanly(String sigAlgorithm, String jcaAlgorithm) throws Exception { + Assumptions.assumeTrue(isBcInstalled() && keyPairs.containsKey(jcaAlgorithm), + "ML-DSA requires BouncyCastle 1.81+"); + + KeyPair kp = keyPairs.get(jcaAlgorithm); + byte[] corrupted = corruptDerEncodedKeyValue(signWithKeyValue(sigAlgorithm, kp)); + + XMLStreamException ex = Assertions.assertThrows(XMLStreamException.class, + () -> verifyInbound(corrupted, new ArrayList<>())); + String chain = messageChain(ex); + Assertions.assertFalse(chain.contains("ArrayIndexOutOfBoundsException"), + "Malformed DEREncodedKeyValue content must not surface as an uncaught RuntimeException: " + chain); + } + + /** Replaces the DEREncodedKeyValue's base64 content with bytes that decode to no known SubjectPublicKeyInfo. */ + private byte[] corruptDerEncodedKeyValue(byte[] signed) throws Exception { + Document document; + try (InputStream is = new ByteArrayInputStream(signed)) { + document = XMLUtils.read(is, false); + } + Element der = (Element) document.getElementsByTagNameNS( + "http://www.w3.org/2009/xmldsig11#", "DEREncodedKeyValue").item(0); + byte[] garbage = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; + NodeList children = der.getChildNodes(); + for (int i = children.getLength() - 1; i >= 0; i--) { + der.removeChild(children.item(i)); + } + der.appendChild(document.createTextNode(Base64.getEncoder().encodeToString(garbage))); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + javax.xml.transform.TransformerFactory.newInstance().newTransformer().transform( + new javax.xml.transform.dom.DOMSource(document), + new javax.xml.transform.stream.StreamResult(bos)); + return bos.toByteArray(); + } + private Document verifyInbound(byte[] signed, List events) throws Exception { XMLSecurityProperties properties = new XMLSecurityProperties(); // deliberately no setSignatureVerificationKey(...) From 2859acc5aadfffc58c69d5dd5ab4c1a4632c0824 Mon Sep 17 00:00:00 2001 From: arpansharma Date: Tue, 25 Aug 2026 14:56:00 -0500 Subject: [PATCH 6/6] Reject malformed DEREncodedKeyValue content cleanly on the DOM path The DOM DEREncodedKeyValue#getPublicKey() has the same narrow exception handling as the StAX token fixed in the previous commit: it caught only NoSuchAlgorithmException and InvalidKeySpecException while iterating the supported key types, so an unchecked exception from a KeyFactorySpi (BouncyCastle 1.85's XDH/EdDSA throw ArrayIndexOutOfBoundsException for malformed or short input) propagated out instead of a clean rejection. Because DEREncodedKeyValueResolver is a default KeyResolver and a DEREncodedKeyValue in an inbound document is untrusted, attacker-controlled content, this could crash key resolution reached via KeyInfo#getPublicKey() with an uncontrolled runtime exception. Catch RuntimeException in the loop so a malformed encoding falls through to the declared XMLSecurityException. Adds a test that fails without the fix (BouncyCastle at first provider position, skipped otherwise). --- .../keys/content/DEREncodedKeyValue.java | 7 +- ...EREncodedKeyValueMalformedContentTest.java | 80 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/apache/xml/security/test/dom/keys/DEREncodedKeyValueMalformedContentTest.java diff --git a/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java b/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java index 85be2f938..6cbd66b5a 100644 --- a/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java +++ b/src/main/java/org/apache/xml/security/keys/content/DEREncodedKeyValue.java @@ -121,8 +121,11 @@ public PublicKey getPublicKey() throws XMLSecurityException { if (publicKey != null) { return publicKey; } - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { //NOPMD - // Do nothing, try the next type + } catch (NoSuchAlgorithmException | InvalidKeySpecException | RuntimeException e) { //NOPMD + // Do nothing, try the next type. Some providers (e.g. BouncyCastle's XDH/EdDSA + // KeyFactorySpi) throw an unchecked exception such as ArrayIndexOutOfBoundsException + // instead of InvalidKeySpecException for malformed or short input, which must not + // propagate since the encoded key here is untrusted, attacker-controlled content. } } throw new XMLSecurityException("DEREncodedKeyValue.UnsupportedEncodedKey"); diff --git a/src/test/java/org/apache/xml/security/test/dom/keys/DEREncodedKeyValueMalformedContentTest.java b/src/test/java/org/apache/xml/security/test/dom/keys/DEREncodedKeyValueMalformedContentTest.java new file mode 100644 index 000000000..65eb7ece1 --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/dom/keys/DEREncodedKeyValueMalformedContentTest.java @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.xml.security.test.dom.keys; + +import java.security.Provider; +import java.security.Security; + +import org.apache.xml.security.exceptions.XMLSecurityException; +import org.apache.xml.security.keys.content.DEREncodedKeyValue; +import org.apache.xml.security.test.dom.TestUtils; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * {@link DEREncodedKeyValue#getPublicKey()} resolves the key by trying each supported key type's + * {@code KeyFactory}. Some providers throw an unchecked exception for malformed or short input + * instead of {@code InvalidKeySpecException} - notably BouncyCastle 1.85's XDH/EdDSA + * KeyFactorySpi throws {@code ArrayIndexOutOfBoundsException}. Since a DEREncodedKeyValue read + * from an inbound document (via {@code DEREncodedKeyValueResolver}, a default KeyResolver) is + * untrusted, attacker-controlled content, such an exception must not propagate out of key + * resolution. + * + *

The unchecked exception is only observed when BouncyCastle is the provider selected for + * XDH/EdDSA, i.e. registered ahead of the JDK's own providers (a common BouncyCastle-primary + * deployment). With the JDK providers taking precedence they reject the same input cleanly with + * {@code InvalidKeySpecException}, so this test inserts BouncyCastle at the first position (as + * {@code XMLCipherTest} does for its BouncyCastle-specific case) and is skipped when BouncyCastle + * is unavailable. + */ +class DEREncodedKeyValueMalformedContentTest { + + @Test + void testMalformedDerContentRejectedCleanly() throws Exception { + boolean bcAtFirstPosition = false; + if (Security.getProvider("BC") == null) { + try { + Class bcClass = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider"); + Provider bc = (Provider) bcClass.getConstructor().newInstance(); + Security.insertProviderAt(bc, 1); + bcAtFirstPosition = true; + } catch (ReflectiveOperationException e) { + // BouncyCastle not installed, ignore + } + } + assumeTrue(bcAtFirstPosition, "requires BouncyCastle at first provider position"); + + try { + Document doc = TestUtils.newDocument(); + // Bytes that decode to no valid SubjectPublicKeyInfo; short enough that BouncyCastle + // 1.85's XDH/EdDSA KeyFactory reads past the end (ArrayIndexOutOfBoundsException). + DEREncodedKeyValue derEncodedKeyValue = + new DEREncodedKeyValue(doc, new byte[]{0, 1, 2, 3, 4, 5, 6, 7}); + + // Must fail cleanly with the declared XMLSecurityException, not an uncaught + // RuntimeException such as ArrayIndexOutOfBoundsException. + assertThrows(XMLSecurityException.class, derEncodedKeyValue::getPublicKey); + } finally { + Security.removeProvider("BC"); + } + } +}