diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 2bd7642cf..ec88429bb 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -32,6 +32,7 @@ exports org.apache.jcp.xml.dsig.internal.dom; exports org.apache.xml.security; + exports org.apache.xml.security.extension; exports org.apache.xml.security.algorithms; exports org.apache.xml.security.algorithms.implementations; exports org.apache.xml.security.c14n; diff --git a/src/main/java/org/apache/xml/security/extension/SignatureExtensionException.java b/src/main/java/org/apache/xml/security/extension/SignatureExtensionException.java new file mode 100644 index 000000000..5dbec9039 --- /dev/null +++ b/src/main/java/org/apache/xml/security/extension/SignatureExtensionException.java @@ -0,0 +1,60 @@ +/** + * 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.extension; + +import org.apache.xml.security.signature.XMLSignatureException; + +/** + * Thrown by a {@link SignatureProcessor} when it cannot complete its processing + * and the signing operation must be aborted. + * + *

Extends {@link XMLSignatureException} so callers that already handle the + * standard library exception hierarchy will catch this automatically. + */ +public class SignatureExtensionException extends XMLSignatureException { + + private static final long serialVersionUID = 1L; + + private final String detailMessage; + + /** + * @param message human-readable description of the failure + */ + public SignatureExtensionException(String message) { + super(message); + this.detailMessage = message; + } + + /** + * @param message human-readable description of the failure + * @param cause the underlying exception that triggered this failure + */ + public SignatureExtensionException(String message, Throwable cause) { + super(message); + this.detailMessage = message; + if (cause != null) { + initCause(cause); + } + } + + @Override + public String getMessage() { + return detailMessage; + } +} diff --git a/src/main/java/org/apache/xml/security/extension/SignatureProcessor.java b/src/main/java/org/apache/xml/security/extension/SignatureProcessor.java new file mode 100644 index 000000000..fcde0336a --- /dev/null +++ b/src/main/java/org/apache/xml/security/extension/SignatureProcessor.java @@ -0,0 +1,57 @@ +/** + * 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.extension; + +import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.signature.XMLSignatureException; + +/** + * Extension point for pluggable pre- and post-signature processing hooks in + * the DOM-based XML Signature implementation. + * + *

Instances are registered on an {@link XMLSignature} via + * {@link XMLSignature#addPreProcessor(SignatureProcessor)} or + * {@link XMLSignature#addPostProcessor(SignatureProcessor)}. + * + *

+ * + *

If a processor throws {@link XMLSignatureException} the signing operation + * is aborted and the exception is propagated to the caller of + * {@link XMLSignature#sign(java.security.Key)}. + * + */ +public interface SignatureProcessor { + + /** + * Called during the {@link XMLSignature#sign(java.security.Key)} lifecycle. + * + * @param signature the signature being created; never {@code null} + * @throws XMLSignatureException if processing fails and signing must be aborted + */ + void processSignature(XMLSignature signature) throws XMLSignatureException; +} 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 658bcaf37..8e1fa9e9d 100644 --- a/src/main/java/org/apache/xml/security/signature/XMLSignature.java +++ b/src/main/java/org/apache/xml/security/signature/XMLSignature.java @@ -27,12 +27,16 @@ import java.security.PublicKey; import java.security.cert.X509Certificate; import java.security.spec.AlgorithmParameterSpec; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; import javax.crypto.SecretKey; import org.apache.xml.security.algorithms.SignatureAlgorithm; import org.apache.xml.security.c14n.Canonicalizer; import org.apache.xml.security.exceptions.XMLSecurityException; +import org.apache.xml.security.extension.SignatureProcessor; import org.apache.xml.security.keys.KeyInfo; import org.apache.xml.security.keys.content.X509Data; import org.apache.xml.security.transforms.Transforms; @@ -249,6 +253,10 @@ public final class XMLSignature extends SignatureElementProxy { private static final int MODE_VERIFY = 1; private int state = MODE_SIGN; + + private final List preProcessors = new ArrayList<>(); + private final List postProcessors = new ArrayList<>(); + /** * This creates a new ds:Signature Element and adds an empty * ds:SignedInfo. @@ -631,6 +639,29 @@ public XMLSignature(Element element, String baseURI, boolean secureValidation, P this.state = MODE_VERIFY; } + + /** + * Registers a pre-processor that is invoked before digest values are computed. + * Pre-processors run in registration order. + * + * @param processor the pre-processor to register; must not be {@code null} + */ + public void addPreProcessor(SignatureProcessor processor) { + Objects.requireNonNull(processor, "processor"); + preProcessors.add(processor); + } + + /** + * Registers a post-processor that is invoked after the {@code ds:SignatureValue} + * element has been populated. Post-processors run in registration order. + * + * @param processor the post-processor to register; must not be {@code null} + */ + public void addPostProcessor(SignatureProcessor processor) { + Objects.requireNonNull(processor, "processor"); + postProcessors.add(processor); + } + /** * Sets the Id attribute * @@ -690,6 +721,32 @@ private void setSignatureValueElement(byte[] bytes) { signatureValueElement.appendChild(t); } + /** + * Sets an {@code Id} attribute on the {@code ds:SignatureValue} element so + * it can be referenced from unsigned signature properties (e.g., XAdES-T). + * + * @param id the identifier value; {@code null} removes an existing attribute + */ + public void setSignatureValueId(String id) { + if (id != null) { + signatureValueElement.setAttributeNS(null, Constants._ATT_ID, id); + signatureValueElement.setIdAttributeNS(null, Constants._ATT_ID, true); + } else { + signatureValueElement.removeAttributeNS(null, Constants._ATT_ID); + } + } + + /** + * Returns the {@code Id} attribute value of the {@code ds:SignatureValue} + * element, or {@code null} if none has been set. + * + * @return the identifier, or {@code null} + */ + public String getSignatureValueId() { + String id = signatureValueElement.getAttributeNS(null, Constants._ATT_ID); + return id.isEmpty() ? null : id; + } + /** * Returns the KeyInfo child. If we are in signing mode and the KeyInfo * does not exist yet, it is created on demand and added to the Signature. @@ -794,6 +851,17 @@ public void sign(Key signingKey) throws XMLSignatureException { ); } + + // snapshot the lists so that concurrent registration during sign() cannot + // cause ConcurrentModificationException or skip newly added processors + List preSnapshot = List.copyOf(preProcessors); + List postSnapshot = List.copyOf(postProcessors); + + // invoke pre-processors before digests are computed + for (SignatureProcessor processor : preSnapshot) { + processor.processSignature(this); + } + //Create a SignatureAlgorithm object SignedInfo si = this.getSignedInfo(); SignatureAlgorithm sa = si.getSignatureAlgorithm(); @@ -816,6 +884,11 @@ public void sign(Key signingKey) throws XMLSignatureException { } catch (XMLSecurityException | IOException ex) { throw new XMLSignatureException(ex); } + + // invoke post-processors after the signature value has been set + for (SignatureProcessor processor : postSnapshot) { + processor.processSignature(this); + } } /** diff --git a/src/test/java/org/apache/xml/security/extension/SignatureProcessorTest.java b/src/test/java/org/apache/xml/security/extension/SignatureProcessorTest.java new file mode 100644 index 000000000..c96ed64a3 --- /dev/null +++ b/src/test/java/org/apache/xml/security/extension/SignatureProcessorTest.java @@ -0,0 +1,253 @@ +/** + * 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.extension; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.List; + +import org.apache.xml.security.Init; +import org.apache.xml.security.algorithms.MessageDigestAlgorithm; +import org.apache.xml.security.signature.XMLSignature; +import org.apache.xml.security.signature.XMLSignatureException; +import org.apache.xml.security.test.dom.TestUtils; +import org.apache.xml.security.transforms.Transforms; +import org.apache.xml.security.utils.Constants; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SignatureProcessorTest { + + private static KeyPair rsaKeyPair; + + @BeforeAll + static void setup() throws Exception { + Init.init(); + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + rsaKeyPair = kpg.generateKeyPair(); + } + + private XMLSignature newSignature(Document doc) throws Exception { + return new XMLSignature(doc, "", XMLSignature.ALGO_ID_SIGNATURE_RSA_SHA256); + } + + @Test + void preProcessorIsInvokedBeforeSignatureValue() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + boolean[] sigValueEmptyInPreProcessor = {false}; + + sig.addPreProcessor(signature -> { + try { + byte[] value = signature.getSignatureValue(); + sigValueEmptyInPreProcessor[0] = (value == null || value.length == 0); + } catch (XMLSignatureException e) { + sigValueEmptyInPreProcessor[0] = true; + } + }); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256); + sig.sign(rsaKeyPair.getPrivate()); + + assertTrue(sigValueEmptyInPreProcessor[0], + "Pre-processor must be called before SignatureValue is populated"); + } + + @Test + void postProcessorIsInvokedAfterSignatureValue() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + boolean[] sigValueSetInPostProcessor = {false}; + + sig.addPostProcessor(signature -> { + try { + byte[] value = signature.getSignatureValue(); + sigValueSetInPostProcessor[0] = (value != null && value.length > 0); + } catch (XMLSignatureException e) { + sigValueSetInPostProcessor[0] = false; + } + }); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256); + sig.sign(rsaKeyPair.getPrivate()); + + assertTrue(sigValueSetInPostProcessor[0], + "Post-processor must be called after SignatureValue is populated"); + } + + @Test + void multiplePreProcessorsAreInvokedInRegistrationOrder() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + List invocationOrder = new ArrayList<>(); + sig.addPreProcessor(signature -> invocationOrder.add("pre-1")); + sig.addPreProcessor(signature -> invocationOrder.add("pre-2")); + sig.addPreProcessor(signature -> invocationOrder.add("pre-3")); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256); + sig.sign(rsaKeyPair.getPrivate()); + + assertEquals(List.of("pre-1", "pre-2", "pre-3"), invocationOrder, + "Pre-processors must be invoked in registration order"); + } + + @Test + void multiplePostProcessorsAreInvokedInRegistrationOrder() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + List invocationOrder = new ArrayList<>(); + sig.addPostProcessor(signature -> invocationOrder.add("post-1")); + sig.addPostProcessor(signature -> invocationOrder.add("post-2")); + sig.addPostProcessor(signature -> invocationOrder.add("post-3")); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256); + sig.sign(rsaKeyPair.getPrivate()); + + assertEquals(List.of("post-1", "post-2", "post-3"), invocationOrder, + "Post-processors must be invoked in registration order"); + } + + @Test + void preProcessorExceptionAbortsSigningAndPropagates() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + sig.addPreProcessor(signature -> { + throw new SignatureExtensionException("pre-processor failure"); + }); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256); + + XMLSignatureException thrown = assertThrows(XMLSignatureException.class, + () -> sig.sign(rsaKeyPair.getPrivate()), + "XMLSignatureException from a pre-processor must propagate out of sign()"); + + assertEquals("pre-processor failure", thrown.getMessage()); + } + + @Test + void postProcessorCanReadSignatureValueId() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + sig.setSignatureValueId("sig-value-id-1"); + + String[] idInPostProcessor = {null}; + sig.addPostProcessor(signature -> idInPostProcessor[0] = signature.getSignatureValueId()); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256); + sig.sign(rsaKeyPair.getPrivate()); + + assertEquals("sig-value-id-1", idInPostProcessor[0], + "Post-processor must read the SignatureValue Id set before signing"); + } + + @Test + void addPreProcessorRejectsNull() throws Exception { + Document doc = TestUtils.newDocument(); + XMLSignature sig = newSignature(doc); + assertThrows(NullPointerException.class, () -> sig.addPreProcessor(null)); + } + + @Test + void addPostProcessorRejectsNull() throws Exception { + Document doc = TestUtils.newDocument(); + XMLSignature sig = newSignature(doc); + assertThrows(NullPointerException.class, () -> sig.addPostProcessor(null)); + } + + @Test + void signatureValueIdRoundTrip() throws Exception { + Document doc = TestUtils.newDocument(); + XMLSignature sig = newSignature(doc); + + assertNull(sig.getSignatureValueId(), "Id must be null before it is set"); + + sig.setSignatureValueId("my-id"); + assertEquals("my-id", sig.getSignatureValueId()); + + sig.setSignatureValueId(null); + assertNull(sig.getSignatureValueId(), "Id must be null after passing null"); + } + + @Test + void signatureRemainsValidWithHooks() throws Exception { + Document doc = TestUtils.newDocument(); + Element root = doc.createElementNS("http://example.org/", "root"); + doc.appendChild(root); + XMLSignature sig = newSignature(doc); + root.appendChild(sig.getElement()); + + sig.addPreProcessor(signature -> { /* no-op */ }); + sig.addPostProcessor(signature -> { /* no-op */ }); + + Transforms transforms = new Transforms(doc); + transforms.addTransform(Transforms.TRANSFORM_ENVELOPED_SIGNATURE); + sig.addDocument("", transforms, Constants.ALGO_ID_DIGEST_SHA1); + sig.sign(rsaKeyPair.getPrivate()); + + PublicKey publicKey = rsaKeyPair.getPublic(); + assertTrue(sig.checkSignatureValue(publicKey), + "Signature produced with no-op hooks must verify correctly"); + } +}