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..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 @@ -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"}; /** @@ -120,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/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/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/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/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..4e8042c05 --- /dev/null +++ b/src/main/java/org/apache/xml/security/stax/impl/securityToken/DEREncodedKeyValueSecurityToken.java @@ -0,0 +1,99 @@ +/** + * 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 | 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"); + } + + @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/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"/> + + + + + + + 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"); + } + } +} diff --git a/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureAbstract.java b/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureAbstract.java index 235e4ade7..af92f83a3 100644 --- a/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureAbstract.java +++ b/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureAbstract.java @@ -28,6 +28,7 @@ import java.util.Collections; import java.util.List; +import javax.xml.crypto.KeySelector; import javax.xml.crypto.dsig.*; import javax.xml.crypto.dsig.dom.DOMSignContext; import javax.xml.crypto.dsig.dom.DOMValidateContext; @@ -149,6 +150,20 @@ protected void assertValidSignatureWithJcpApi(byte[] signedXml, boolean register } } + /** + * Validates a signed document using the given {@link KeySelector} instead of the + * always-embedded-cert {@link KeySelectors.RawX509KeySelector}, and returns the core + * validity result rather than asserting it - for negative tests that expect validation + * to fail (tampered signature, wrong key, etc). + */ + protected boolean validateSignatureWithJcpApi(byte[] signedXml, KeySelector keySelector) throws Exception { + try (InputStream is = new ByteArrayInputStream(signedXml)) { + DOMValidateContext vc = testInstance.getValidateContext(is, keySelector, false); + updateIdReferences(vc, "SignedElement", "id"); + return testInstance.validate(vc); + } + } + public void updateIdReferences(DOMValidateContext vc, String elementName, String idAttributeName) { Document doc = vc.getNode().getOwnerDocument(); NodeList nl = doc.getElementsByTagName(elementName); 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 new file mode 100644 index 000000000..29b3f0bf6 --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/javax/xml/crypto/dsig/XMLSignatureMLDSATest.java @@ -0,0 +1,214 @@ +/** + * 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.javax.xml.crypto.dsig; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PublicKey; +import java.security.Provider; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Base64; + +import javax.xml.crypto.AlgorithmMethod; +import javax.xml.crypto.KeySelector; +import javax.xml.crypto.KeySelectorException; +import javax.xml.crypto.KeySelectorResult; +import javax.xml.crypto.XMLCryptoContext; +import javax.xml.crypto.XMLStructure; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; + +import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.test.javax.xml.crypto.KeySelectors; +import org.apache.xml.security.testutils.SelfSignedCertGenerator; +import org.apache.xml.security.utils.Constants; +import org.apache.xml.security.utils.XMLUtils; +import org.junit.jupiter.api.AfterAll; +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; + +/** + * Tests for ML-DSA (FIPS 204) XML digital signatures via the + * {@code javax.xml.crypto.dsig.XMLSignatureFactory} DOM API. + * + *

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); + } + + @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(signatureAlgorithmURI, alias, false); + + byte[] tamperedXml = flipByteInSignatureValue(signedXml); + + boolean coreValidity = validateSignatureWithJcpApi(tamperedXml, new KeySelectors.RawX509KeySelector()); + Assertions.assertFalse(coreValidity, "A tampered SignatureValue must not validate"); + } + + @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(signatureAlgorithmURI, alias, false); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alias.toUpperCase(), "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/StaxMLDSAKeyValueInboundTest.java b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java new file mode 100644 index 000000000..5b93e604f --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSAKeyValueInboundTest.java @@ -0,0 +1,313 @@ +/** + * 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(); + } + + /** + * 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(...) + 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(); + } +} 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); + } + } +} 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..0fd45b669 --- /dev/null +++ b/src/test/java/org/apache/xml/security/test/stax/signature/StaxMLDSASignatureTest.java @@ -0,0 +1,199 @@ +/** + * 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.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()); + } + + @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 = signWith(sigAlgorithm, jcaAlgorithm); + Element sigElement = tamperSignatureValue(document); + + XMLSignature signature = new XMLSignature(sigElement, ""); + boolean coreValidity = signature.checkSignatureValue(keyPairs.get(jcaAlgorithm).getPublic()); + Assertions.assertFalse(coreValidity, "A tampered SignatureValue must not validate"); + } + + @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 = signWith(sigAlgorithm, jcaAlgorithm); + Element sigElement = (Element) document.getElementsByTagNameNS(Constants.SignatureSpecNS, "Signature").item(0); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance(jcaAlgorithm, "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 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(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); + + 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; + } +}