diff --git a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/AS2ServerConnection.java b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/AS2ServerConnection.java index b098261bd48bd..7f670765dfeb1 100644 --- a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/AS2ServerConnection.java +++ b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/AS2ServerConnection.java @@ -21,8 +21,12 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; +import java.net.URI; import java.security.PrivateKey; import java.security.cert.Certificate; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -35,9 +39,7 @@ import org.apache.camel.component.as2.api.io.AS2BHttpServerConnection; import org.apache.camel.component.as2.api.protocol.ResponseMDN; import org.apache.camel.util.ObjectHelper; -import org.apache.hc.core5.http.ConnectionClosedException; -import org.apache.hc.core5.http.HttpException; -import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.*; import org.apache.hc.core5.http.config.Http1Config; import org.apache.hc.core5.http.impl.io.HttpService; import org.apache.hc.core5.http.io.HttpRequestHandler; @@ -64,57 +66,272 @@ public class AS2ServerConnection { private static final String REQUEST_LISTENER_THREAD_NAME_PREFIX = "AS2Svr-"; private static final String REQUEST_HANDLER_THREAD_NAME_PREFIX = "AS2Hdlr-"; - class RequestListenerThread extends Thread { + public static final String AS2_DECRYPTING_PRIVATE_KEY = "AS2_DECRYPTING_PRIVATE_KEY"; + public static final String AS2_VALIDATE_SIGNING_CERTIFICATE_CHAIN = "AS2_VALIDATE_SIGNING_CERTIFICATE_CHAIN"; + public static final String AS2_SIGNING_PRIVATE_KEY = "AS2_SIGNING_PRIVATE_KEY"; + public static final String AS2_SIGNING_CERTIFICATE_CHAIN = "AS2_SIGNING_CERTIFICATE_CHAIN"; + public static final String AS2_SIGNING_ALGORITHM = "AS2_SIGNING_ALGORITHM"; + + private ServerSocket serversocket; + private RequestListenerService listenerService; + private RequestAcceptorThread acceptorThread; + private final Lock lock = new ReentrantLock(); + + private final String as2Version; + private final String originServer; + private final String serverFqdn; + private final Certificate[] signingCertificateChain; + private final PrivateKey signingPrivateKey; + private final PrivateKey decryptingPrivateKey; + private final Certificate[] validateSigningCertificateChain; + private final AS2SignatureAlgorithm signingAlgorithm; + + /** + * Stores the configuration for each consumer endpoint path (e.g., "/consumerA") + */ + private final Map consumerConfigurations = new ConcurrentHashMap<>(); + + /** + * Simple wrapper class to associate the AS2ConsumerConfiguration with the specific request URI path that was + * matched. Used exclusively by the ThreadLocal state. + */ + private static class ThreadLocalConfigWrapper { + final AS2ConsumerConfiguration config; + final String requestUriPath; + + ThreadLocalConfigWrapper(AS2ConsumerConfiguration config, String requestUriPath) { + this.config = config; + this.requestUriPath = requestUriPath; + } + } + + /** + * Stores the request-specific AS2ConsumerConfiguration and path. Used for post-processing logic (like asynchronous + * MDN) after the main HttpService handling is complete. + */ + private static final ThreadLocal CURRENT_CONSUMER_CONFIG = new ThreadLocal<>(); + + /** + * Configuration data holding all necessary security material (signing keys/certs and decryption keys/certs) for a + * single AS2 consumer endpoint. This immutable object is looked up per request URI. + */ + public static class AS2ConsumerConfiguration { + private final Certificate[] signingCertificateChain; + private final PrivateKey signingPrivateKey; + private final PrivateKey decryptingPrivateKey; + private final Certificate[] validateSigningCertificateChain; + private final AS2SignatureAlgorithm signingAlgorithm; + + public AS2ConsumerConfiguration( + AS2SignatureAlgorithm signingAlgorithm, + Certificate[] signingCertificateChain, + PrivateKey signingPrivateKey, + PrivateKey decryptingPrivateKey, + Certificate[] validateSigningCertificateChain) { + this.signingAlgorithm = signingAlgorithm; + this.signingCertificateChain = signingCertificateChain; + this.signingPrivateKey = signingPrivateKey; + this.decryptingPrivateKey = decryptingPrivateKey; + this.validateSigningCertificateChain = validateSigningCertificateChain; + } + + // Getters + public Certificate[] getValidateSigningCertificateChain() { + return validateSigningCertificateChain; + } + + public Certificate[] getSigningCertificateChain() { + return signingCertificateChain; + } + + public AS2SignatureAlgorithm getSigningAlgorithm() { + return signingAlgorithm; + } + + public PrivateKey getSigningPrivateKey() { + return signingPrivateKey; + } + + public PrivateKey getDecryptingPrivateKey() { + return decryptingPrivateKey; + } + } + + /** + * Retrieves the specific AS2 consumer configuration associated with the given request path. + * + * @param path The canonical request URI path (e.g., "/consumerA"). + * @return An Optional containing the configuration if a match is found, otherwise empty. + */ + public Optional getConfigurationForPath(String path) { + return Optional.ofNullable(consumerConfigurations.get(path)); + } + + /** + * Dynamically determines and injects the AS2 security configuration (keys, certificates, and algorithm) for the + * incoming HTTP request. + * + * This method performs three main tasks: 1. Looks up the correct AS2ConsumerConfiguration based on the request URI + * path. 2. Injects the decryption and signing security material into the HttpContext for use by downstream + * processors (like the AS2Consumer and ResponseMDN). 3. Stores the configuration in a ThreadLocal for use by + * asynchronous MDN logic. + * + * @param request The incoming HTTP request. + * @param context The shared execution context for the request lifecycle. + * @return The AS2ConsumerConfiguration object found, or null if none was matched. + */ + private AS2ConsumerConfiguration setupConfigurationForRequest(ClassicHttpRequest request, HttpContext context) { + String requestUri = request.getRequestUri(); + String requestUriPath = cleanUpPath(requestUri); + + // 1. LOOKUP: Find the specific consumer configuration + AS2ConsumerConfiguration config = AS2ServerConnection.this + .getConfigurationForPath(requestUriPath).orElse(null); + + // 2. Logging BEFORE injection (CRITICAL for debugging path issues) + LOG.debug("Processing request. Incoming URI: {}, Canonical Path: {}. Config Found: {}", + requestUri, requestUriPath, (config != null)); + + // 3. Handle missing config + if (config == null) { + LOG.warn("No AS2 consumer configuration found for canonical path: {}. Encrypted messages will likely fail.", + requestUriPath); + return null; + } + + // 4. INJECTION: Inject dynamic security keys into the HttpContext + context.setAttribute(AS2_DECRYPTING_PRIVATE_KEY, config.getDecryptingPrivateKey()); + context.setAttribute(AS2_VALIDATE_SIGNING_CERTIFICATE_CHAIN, config.getValidateSigningCertificateChain()); + context.setAttribute(AS2_SIGNING_PRIVATE_KEY, config.getSigningPrivateKey()); + context.setAttribute(AS2_SIGNING_CERTIFICATE_CHAIN, config.getSigningCertificateChain()); + context.setAttribute(AS2_SIGNING_ALGORITHM, config.getSigningAlgorithm()); + + // 5. CRITICAL READ-BACK CHECK: Immediately check if the key is retrievable from the context + Object checkKey = context.getAttribute(AS2_DECRYPTING_PRIVATE_KEY); + + if (checkKey == null) { + LOG.error( + "FATAL: Decrypting Private Key failed to be read back from HttpContext immediately after injection for path: {}", + requestUriPath); + } else if (!(checkKey instanceof PrivateKey)) { + LOG.error("FATAL: Key in HttpContext is not a PrivateKey object! Found type: {}", checkKey.getClass().getName()); + } else { + LOG.debug("Context injection confirmed: Decrypting Key set successfully into HttpContext. Key type: {}", + checkKey.getClass().getName()); + } + + // 6. Set ThreadLocal for later MDN processing + ThreadLocalConfigWrapper wrapper = new ThreadLocalConfigWrapper(config, requestUriPath); + CURRENT_CONSUMER_CONFIG.set(wrapper); + + return config; + } + + /** + * Extracts and normalizes the path component from the request URI. + * + * This ensures consistency by stripping query parameters and scheme/authority, and defaults to "/" if the path is + * empty or parsing fails. + * + * @param requestUri The full request URI string from the HTTP request line. + * @return The canonical path, starting with a "/", without query parameters. + */ + private String cleanUpPath(String requestUri) { + try { + URI uri = new URI(requestUri); + String path = uri.getPath(); + // Ensure path is not null and normalize to "/" if it is empty/null after parsing + if (path == null || path.isEmpty()) { + return "/"; + } + return path; + } catch (Exception e) { + // Should not happen for a valid HTTP request line + LOG.warn("Error parsing request URI: {}", requestUri, e); + return "/"; // Default to root path in case of error + } + } + + /** + * Interceptor that executes early in the request processing chain to find the correct + * {@link AS2ConsumerConfiguration} for the incoming request URI and injects its security material + * (keys/certs/algorithm) into the {@link HttpContext} and {@link ThreadLocal} storage. + */ + private class AS2ConsumerConfigInterceptor implements HttpRequestInterceptor { + + @Override + public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) + throws HttpException, IOException { + if (request instanceof ClassicHttpRequest) { + // Now safely calling the method on the outer class instance (AS2ServerConnection.this) + AS2ServerConnection.this.setupConfigurationForRequest((ClassicHttpRequest) request, context); + } + } + } + + class RequestListenerService { - private final ServerSocket serversocket; private final HttpService httpService; private final RequestHandlerRegistry registry; - private final HttpServerRequestHandler handler; - - public RequestListenerThread(String as2Version, - String originServer, - String serverFqdn, - int port, - AS2SignatureAlgorithm signatureAlgorithm, - Certificate[] signingCertificateChain, - PrivateKey signingPrivateKey, - PrivateKey decryptingPrivateKey, - String mdnMessageTemplate, - Certificate[] validateSigningCertificateChain, - SSLContext sslContext) - throws IOException { - setName(REQUEST_LISTENER_THREAD_NAME_PREFIX + port); - if (sslContext == null) { - serversocket = new ServerSocket(port); - } else { - SSLServerSocketFactory factory = sslContext.getServerSocketFactory(); - serversocket = factory.createServerSocket(port); - } + public RequestListenerService(String as2Version, + String originServer, + String serverFqdn, + String mdnMessageTemplate, + Certificate[] validateSigningCertificateChain) + throws IOException { // Set up HTTP protocol processor for incoming connections - final HttpProcessor inhttpproc = initProtocolProcessor(as2Version, originServer, serverFqdn, - signatureAlgorithm, signingCertificateChain, signingPrivateKey, decryptingPrivateKey, mdnMessageTemplate, - validateSigningCertificateChain); + final HttpProcessor inhttpproc = initProtocolProcessor( + as2Version, originServer, serverFqdn, + mdnMessageTemplate, validateSigningCertificateChain); registry = new RequestHandlerRegistry<>(); - handler = new BasicHttpServerRequestHandler(registry); + HttpServerRequestHandler handler = new BasicHttpServerRequestHandler(registry); // Set up the HTTP service httpService = new HttpService(inhttpproc, handler); } + void registerHandler(String requestUriPattern, HttpRequestHandler httpRequestHandler) { + registry.register(null, requestUriPattern, httpRequestHandler); + } + + void unregisterHandler(String requestUriPattern) { + // we cannot remove from http registry, but we can replace with a not found to simulate 404 + registry.register(null, requestUriPattern, new NotFoundHttpRequestHandler()); + } + } + + class RequestAcceptorThread extends Thread { + + private final RequestListenerService service; + + public RequestAcceptorThread(int port, SSLContext sslContext, RequestListenerService service) + throws IOException { + setName(REQUEST_LISTENER_THREAD_NAME_PREFIX + port); + this.service = service; + + // 2. BIND THE PORT HERE! This happens only once. + if (sslContext == null) { + serversocket = new ServerSocket(port); + } else { + SSLServerSocketFactory factory = sslContext.getServerSocketFactory(); + serversocket = factory.createServerSocket(port); + } + } + @Override public void run() { - LOG.info("Listening on port {}", this.serversocket.getLocalPort()); + // serversocket is now a field of the outer AS2ServerConnection class + LOG.info("Listening on port {}", serversocket.getLocalPort()); while (!Thread.interrupted()) { try { - // Set up incoming HTTP connection - final Socket inSocket = this.serversocket.accept(); + final Socket inSocket = serversocket.accept(); - // Start worker thread - final Thread t = new RequestHandlerThread(this.httpService, inSocket); + // Start worker thread, using the service's HttpService + final Thread t = new RequestHandlerThread(this.service.httpService, inSocket); t.setDaemon(true); t.start(); } catch (final InterruptedIOException | SocketException ex) { @@ -126,15 +343,6 @@ public void run() { } } } - - void registerHandler(String requestUriPattern, HttpRequestHandler httpRequestHandler) { - registry.register(null, requestUriPattern, httpRequestHandler); - } - - void unregisterHandler(String requestUriPattern) { - // we cannot remove from http registry, but we can replace with a not found to simulate 404 - registry.register(null, requestUriPattern, new NotFoundHttpRequestHandler()); - } } class RequestHandlerThread extends Thread { @@ -165,22 +373,34 @@ public void run() { this.httpService.handleRequest(this.serverConnection, context); HttpCoreContext coreContext = HttpCoreContext.adapt(context); + + // Safely retrieve the AS2 consumer configuration and path from ThreadLocal storage. + AS2ConsumerConfiguration config = Optional.ofNullable(CURRENT_CONSUMER_CONFIG.get()) + .map(w -> w.config) + .orElse(null); + String recipientAddress = coreContext.getAttribute(AS2AsynchronousMDNManager.RECIPIENT_ADDRESS, String.class); - if (recipientAddress != null) { + if (recipientAddress != null && config != null) { // Send the MDN asynchronously. DispositionNotificationMultipartReportEntity multipartReportEntity = coreContext.getAttribute( AS2AsynchronousMDNManager.ASYNCHRONOUS_MDN, DispositionNotificationMultipartReportEntity.class); AS2AsynchronousMDNManager asynchronousMDNManager = new AS2AsynchronousMDNManager( - as2Version, - originServer, serverFqdn, signingCertificateChain, signingPrivateKey); + AS2ServerConnection.this.as2Version, + AS2ServerConnection.this.originServer, + AS2ServerConnection.this.serverFqdn, + config.getSigningCertificateChain(), + config.getSigningPrivateKey()); HttpRequest request = coreContext.getAttribute(HttpCoreContext.HTTP_REQUEST, HttpRequest.class); AS2SignedDataGenerator gen = ResponseMDN.createSigningGenerator( - request, signingAlgorithm, signingCertificateChain, signingPrivateKey); + request, + config.getSigningAlgorithm(), + config.getSigningCertificateChain(), + config.getSigningPrivateKey()); MultipartMimeEntity asyncReceipt = multipartReportEntity; if (gen != null) { @@ -219,17 +439,6 @@ public void run() { } - private RequestListenerThread listenerThread; - private final Lock lock = new ReentrantLock(); - private final String as2Version; - private final String originServer; - private final String serverFqdn; - private final Certificate[] signingCertificateChain; - private final PrivateKey signingPrivateKey; - private final PrivateKey decryptingPrivateKey; - private final Certificate[] validateSigningCertificateChain; - private final AS2SignatureAlgorithm signingAlgorithm; - public AS2ServerConnection(String as2Version, String originServer, String serverFqdn, @@ -250,38 +459,67 @@ public AS2ServerConnection(String as2Version, this.signingPrivateKey = signingPrivateKey; this.decryptingPrivateKey = decryptingPrivateKey; this.validateSigningCertificateChain = validateSigningCertificateChain; - this.signingAlgorithm = signingAlgorithm; - listenerThread = new RequestListenerThread( - this.as2Version, this.originServer, this.serverFqdn, - parserServerPortNumber, signingAlgorithm, this.signingCertificateChain, this.signingPrivateKey, - this.decryptingPrivateKey, mdnMessageTemplate, validateSigningCertificateChain, sslContext); - listenerThread.setDaemon(true); - listenerThread.start(); + + // Create and register a default consumer configuration for the root path ('/'). + // This ensures that all incoming requests have a fallback configuration for decryption + // and MDN signing, even if they don't match a specific Camel route path. + AS2ServerConnection.AS2ConsumerConfiguration consumerConfig = new AS2ServerConnection.AS2ConsumerConfiguration( + signingAlgorithm, + signingCertificateChain, + signingPrivateKey, + decryptingPrivateKey, + validateSigningCertificateChain); + registerConsumerConfiguration("/", consumerConfig); + + listenerService = new RequestListenerService( + this.as2Version, + this.originServer, + this.serverFqdn, + mdnMessageTemplate, + validateSigningCertificateChain); + + acceptorThread = new RequestAcceptorThread(parserServerPortNumber, sslContext, listenerService); + acceptorThread.setDaemon(true); + acceptorThread.start(); } public Certificate[] getValidateSigningCertificateChain() { - return validateSigningCertificateChain; + return Optional.ofNullable(CURRENT_CONSUMER_CONFIG.get()) + .map(w -> w.config.getValidateSigningCertificateChain()) + .orElse(null); } public PrivateKey getSigningPrivateKey() { - return signingPrivateKey; + return Optional.ofNullable(CURRENT_CONSUMER_CONFIG.get()) + .map(w -> w.config.getSigningPrivateKey()) + .orElse(null); } public PrivateKey getDecryptingPrivateKey() { - return decryptingPrivateKey; + return Optional.ofNullable(CURRENT_CONSUMER_CONFIG.get()) + .map(w -> w.config.getDecryptingPrivateKey()) + .orElse(null); + } + + public void registerConsumerConfiguration(String path, AS2ConsumerConfiguration config) { + consumerConfigurations.put(path, config); } public void close() { - if (listenerThread != null) { + if (acceptorThread != null) { lock.lock(); try { try { - listenerThread.serversocket.close(); + // 3. Close the shared ServerSocket + if (serversocket != null) { + serversocket.close(); + } } catch (IOException e) { LOG.debug(e.getMessage(), e); } finally { - listenerThread = null; + acceptorThread = null; + listenerService = null; } } finally { lock.unlock(); @@ -290,10 +528,10 @@ public void close() { } public void listen(String requestUri, HttpRequestHandler handler) { - if (listenerThread != null) { + if (listenerService != null) { lock.lock(); try { - listenerThread.registerHandler(requestUri, handler); + listenerService.registerHandler(requestUri, handler); } finally { lock.unlock(); } @@ -301,10 +539,11 @@ public void listen(String requestUri, HttpRequestHandler handler) { } public void unlisten(String requestUri) { - if (listenerThread != null) { + if (listenerService != null) { lock.lock(); try { - listenerThread.unregisterHandler(requestUri); + listenerService.unregisterHandler(requestUri); + consumerConfigurations.remove(requestUri); } finally { lock.unlock(); } @@ -315,17 +554,15 @@ protected HttpProcessor initProtocolProcessor( String as2Version, String originServer, String serverFqdn, - AS2SignatureAlgorithm signatureAlgorithm, - Certificate[] signingCertificateChain, - PrivateKey signingPrivateKey, - PrivateKey decryptingPrivateKey, String mdnMessageTemplate, Certificate[] validateSigningCertificateChain) { - return HttpProcessorBuilder.create().add(new ResponseContent(true)).add(new ResponseServer(originServer)) - .add(new ResponseDate()).add(new ResponseConnControl()).add(new ResponseMDN( - as2Version, serverFqdn, - signatureAlgorithm, signingCertificateChain, signingPrivateKey, decryptingPrivateKey, - mdnMessageTemplate, validateSigningCertificateChain)) + return HttpProcessorBuilder.create() + .addFirst(new AS2ConsumerConfigInterceptor()) // Sets up the request-specific keys and certificates in the HttpContext + .add(new ResponseContent(true)) + .add(new ResponseServer(originServer)) + .add(new ResponseDate()) + .add(new ResponseConnControl()) + .add(new ResponseMDN(as2Version, serverFqdn, mdnMessageTemplate)) .build(); } diff --git a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/protocol/ResponseMDN.java b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/protocol/ResponseMDN.java index e4849b9bab2e4..ec7c368a9e9ff 100644 --- a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/protocol/ResponseMDN.java +++ b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/protocol/ResponseMDN.java @@ -27,14 +27,7 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; -import org.apache.camel.component.as2.api.AS2AsynchronousMDNManager; -import org.apache.camel.component.as2.api.AS2Constants; -import org.apache.camel.component.as2.api.AS2Header; -import org.apache.camel.component.as2.api.AS2ServerManager; -import org.apache.camel.component.as2.api.AS2SignatureAlgorithm; -import org.apache.camel.component.as2.api.AS2SignedDataGenerator; -import org.apache.camel.component.as2.api.AS2TransferEncoding; -import org.apache.camel.component.as2.api.InvalidAS2NameException; +import org.apache.camel.component.as2.api.*; import org.apache.camel.component.as2.api.entity.AS2DispositionModifier; import org.apache.camel.component.as2.api.entity.AS2DispositionType; import org.apache.camel.component.as2.api.entity.DispositionMode; @@ -85,16 +78,29 @@ public class ResponseMDN implements HttpResponseInterceptor { private final String as2Version; private final String serverFQDN; - private final AS2SignatureAlgorithm signingAlgorithm; - private final Certificate[] signingCertificateChain; - private final PrivateKey signingPrivateKey; - private final PrivateKey decryptingPrivateKey; private final String mdnMessageTemplate; - private final Certificate[] validateSigningCertificateChain; + + private AS2SignatureAlgorithm signingAlgorithm; + private Certificate[] signingCertificateChain; + private PrivateKey signingPrivateKey; + private PrivateKey decryptingPrivateKey; + private Certificate[] validateSigningCertificateChain; + private boolean keysAreDynamic = false; // Flag indicating if security keys/certs must be dynamically fetched from the HttpContext private final Lock lock = new ReentrantLock(); private VelocityEngine velocityEngine; + public ResponseMDN(String as2Version, String serverFQDN, String mdnMessageTemplate) { + this.as2Version = as2Version; + this.serverFQDN = serverFQDN; + if (!StringUtils.isBlank(mdnMessageTemplate)) { + this.mdnMessageTemplate = mdnMessageTemplate; + } else { + this.mdnMessageTemplate = DEFAULT_MDN_MESSAGE_TEMPLATE; + } + this.keysAreDynamic = true; + } + public ResponseMDN(String as2Version, String serverFQDN, AS2SignatureAlgorithm signingAlgorithm, Certificate[] signingCertificateChain, PrivateKey signingPrivateKey, PrivateKey decryptingPrivateKey, String mdnMessageTemplate, Certificate[] validateSigningCertificateChain) { @@ -126,6 +132,18 @@ public void process(HttpResponse response, EntityDetails entity, HttpContext con return; } + if (this.keysAreDynamic) { + // Dynamically load path-specific security material from the HttpContext, + // which was populated by AS2ConsumerConfigInterceptor. + this.signingAlgorithm = (AS2SignatureAlgorithm) context.getAttribute(AS2ServerConnection.AS2_SIGNING_ALGORITHM); + this.signingCertificateChain + = (Certificate[]) context.getAttribute(AS2ServerConnection.AS2_SIGNING_CERTIFICATE_CHAIN); + this.signingPrivateKey = (PrivateKey) context.getAttribute(AS2ServerConnection.AS2_SIGNING_PRIVATE_KEY); + this.decryptingPrivateKey = (PrivateKey) context.getAttribute(AS2ServerConnection.AS2_DECRYPTING_PRIVATE_KEY); + this.validateSigningCertificateChain + = (Certificate[]) context.getAttribute(AS2ServerConnection.AS2_VALIDATE_SIGNING_CERTIFICATE_CHAIN); + } + HttpCoreContext coreContext = HttpCoreContext.adapt(context); HttpRequest request = coreContext.getAttribute(HttpCoreContext.HTTP_REQUEST, HttpRequest.class); diff --git a/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Consumer.java b/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Consumer.java index e05dacc1f39c4..a9ca56ca77b23 100644 --- a/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Consumer.java +++ b/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Consumer.java @@ -34,7 +34,6 @@ import org.apache.camel.support.component.AbstractApiConsumer; import org.apache.camel.support.component.ApiConsumerHelper; import org.apache.camel.support.component.ApiMethod; -import org.apache.camel.support.component.ApiMethodHelper; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.HttpEntityContainer; @@ -98,18 +97,36 @@ protected void doStart() throws Exception { as2ServerConnection = getEndpoint().getAS2ServerConnection(); apiProxy = new AS2ServerManager(as2ServerConnection); - // invoke the API method to start listening - ApiMethodHelper.invokeMethod(apiProxy, apiMethod, properties); + String uri = properties.computeIfAbsent("requestUriPattern", param -> "/").toString(); + + // Check if the configuration for this specific URI path has already been registered + // (e.g., by the default "/" fallback or another consumer). + // If not, create and register it now using the endpoint's configured keys/certs. + if (as2ServerConnection.getConfigurationForPath(uri).isEmpty()) { + AS2ServerConnection.AS2ConsumerConfiguration consumerConfig = new AS2ServerConnection.AS2ConsumerConfiguration( + getEndpoint().getSigningAlgorithm(), + getEndpoint().getSigningCertificateChain(), + getEndpoint().getSigningPrivateKey(), + getEndpoint().getDecryptingPrivateKey(), + getEndpoint().getValidateSigningCertificateChain()); + as2ServerConnection.registerConsumerConfiguration(uri, consumerConfig); + } + + as2ServerConnection.listen(uri, this); } @Override protected void doStop() throws Exception { - super.doStop(); - if (apiProxy != null) { - String uri = properties.get("requestUriPattern").toString(); - apiProxy.unlisten(uri); + if (as2ServerConnection != null) { + // Resolve the unique URI pattern for this consumer + String uri = properties.computeIfAbsent("requestUriPattern", param -> "/").toString(); + + // Unregister this consumer from the shared AS2ServerConnection + as2ServerConnection.unlisten(uri); } + + super.doStop(); } @Override @@ -125,8 +142,8 @@ public void handle(ClassicHttpRequest request, ClassicHttpResponse response, Htt ApplicationEntity ediEntity = HttpMessageUtils.extractEdiPayload(request, new HttpMessageUtils.DecrpytingAndSigningInfo( - as2ServerConnection.getValidateSigningCertificateChain(), - as2ServerConnection.getDecryptingPrivateKey())); + getEndpoint().getValidateSigningCertificateChain(), + getEndpoint().getDecryptingPrivateKey())); // Set AS2 Interchange property and EDI message into body of input message. Exchange exchange = createExchange(false); diff --git a/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Endpoint.java b/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Endpoint.java index d70ead7b41079..4212fbcf8562e 100644 --- a/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Endpoint.java +++ b/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/AS2Endpoint.java @@ -198,6 +198,22 @@ public void setSigningCertificateChain(Certificate[] signingCertificateChain) { configuration.setSigningCertificateChain(signingCertificateChain); } + public Certificate[] getValidateSigningCertificateChain() { + return configuration.getValidateSigningCertificateChain(); + } + + public void setValidateSigningCertificateChain(Certificate[] validateSigningCertificateChain) { + configuration.setValidateSigningCertificateChain(validateSigningCertificateChain); + } + + public PrivateKey getDecryptingPrivateKey() { + return configuration.getDecryptingPrivateKey(); + } + + public void setDecryptingPrivateKey(PrivateKey decryptingPrivateKey) { + configuration.setDecryptingPrivateKey(decryptingPrivateKey); + } + public PrivateKey getSigningPrivateKey() { return configuration.getSigningPrivateKey(); } diff --git a/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/internal/AS2ConnectionHelper.java b/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/internal/AS2ConnectionHelper.java index c43297fa924f7..9a8798e126578 100644 --- a/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/internal/AS2ConnectionHelper.java +++ b/components/camel-as2/camel-as2-component/src/main/java/org/apache/camel/component/as2/internal/AS2ConnectionHelper.java @@ -103,9 +103,12 @@ public static AS2ServerConnection createAS2ServerConnection(AS2Configuration con configuration.getAs2Version(), configuration.getServer(), configuration.getServerFqdn(), configuration.getServerPortNumber(), configuration.getSigningAlgorithm(), - configuration.getSigningCertificateChain(), configuration.getSigningPrivateKey(), - configuration.getDecryptingPrivateKey(), configuration.getMdnMessageTemplate(), - configuration.getValidateSigningCertificateChain(), configuration.getSslContext()); + configuration.getSigningCertificateChain(), + configuration.getSigningPrivateKey(), + configuration.getDecryptingPrivateKey(), + configuration.getMdnMessageTemplate(), + configuration.getValidateSigningCertificateChain(), + configuration.getSslContext()); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/components/camel-as2/camel-as2-component/src/test/java/org/apache/camel/component/as2/AS2ServerTwoConsumerBase.java b/components/camel-as2/camel-as2-component/src/test/java/org/apache/camel/component/as2/AS2ServerTwoConsumerBase.java new file mode 100644 index 0000000000000..c39ed9cada126 --- /dev/null +++ b/components/camel-as2/camel-as2-component/src/test/java/org/apache/camel/component/as2/AS2ServerTwoConsumerBase.java @@ -0,0 +1,244 @@ +/* + * 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.camel.component.as2; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.as2.api.*; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.protocol.HttpCoreContext; + +public class AS2ServerTwoConsumerBase extends AS2ServerSecTestBase { + + public enum ConsumerConfig { + CONSUMER_A("AS2_SERVER_A", "keyPairA", "signingKeyA", "/consumerA"), + CONSUMER_B("AS2_SERVER_B", "keyPairB", "signingKeyB", "/consumerB"); + + private final String as2To; + private final String decryptingKey; + private final String signingKey; + private final String requestUriPattern; // New field + + ConsumerConfig(String as2To, String decryptingKey, String signingKey, String requestUriPattern) { + this.as2To = as2To; + this.decryptingKey = decryptingKey; + this.signingKey = signingKey; + this.requestUriPattern = requestUriPattern; + } + + public String getAs2To() { + return as2To; + } + + public String getDecryptingKey() { + return decryptingKey; + } + + public String getSigningKey() { + return signingKey; + } + + public String getRequestUriPattern() { + return requestUriPattern; + } // New getter + } + + private KeyPair decryptingKPA; + private KeyPair decryptingKPB; + private X509Certificate signingCertA; + private X509Certificate signingCertB; + + @Override + protected CamelContext createCamelContext() throws Exception { + // 1. Let the base class create the context instance + CamelContext context = super.createCamelContext(); + + // 2. Generate and assign distinct keys for Consumer A + Object[] setA = generateNewKeyPairSet(ConsumerConfig.CONSUMER_A.getAs2To()); + decryptingKPA = (KeyPair) setA[1]; + signingCertA = (X509Certificate) setA[2]; + + // 3. Generate and assign distinct keys for Consumer B + Object[] setB = generateNewKeyPairSet(ConsumerConfig.CONSUMER_B.getAs2To()); + decryptingKPB = (KeyPair) setB[1]; + signingCertB = (X509Certificate) setB[2]; + + // 4. Register private keys in the registry for the AS2 consumers to find. + // The 'context' object is guaranteed to be non-null here. + context.getRegistry().bind(ConsumerConfig.CONSUMER_A.getDecryptingKey(), decryptingKPA.getPrivate()); + context.getRegistry().bind(ConsumerConfig.CONSUMER_B.getDecryptingKey(), decryptingKPB.getPrivate()); + + context.getRegistry().bind(ConsumerConfig.CONSUMER_A.getSigningKey(), decryptingKPA.getPrivate()); + context.getRegistry().bind(ConsumerConfig.CONSUMER_B.getSigningKey(), decryptingKPB.getPrivate()); + + // 5. Return the configured context + return context; + } + + @Override + protected RouteBuilder createRouteBuilder() { + // Define both consumers listening on the same component but different URIs + return new RouteBuilder() { + public void configure() { + // Consumer A: Uses keys registered as 'keyPairA' + from("as2://server/listen?requestUriPattern=/consumerA&decryptingPrivateKey=#" + + ConsumerConfig.CONSUMER_A.getDecryptingKey() + "&signingPrivateKey=#" + + ConsumerConfig.CONSUMER_A.getSigningKey()) + .to("mock:consumerA"); + + // Consumer B: Uses keys registered as 'keyPairB' + from("as2://server/listen?requestUriPattern=/consumerB&decryptingPrivateKey=#" + + ConsumerConfig.CONSUMER_B.getDecryptingKey() + "&signingPrivateKey=#" + + ConsumerConfig.CONSUMER_B.getSigningKey()) + .to("mock:consumerB"); + } + }; + } + + protected HttpCoreContext sendToConsumerA(AS2MessageStructure structure) throws Exception { + // For testing, we use Consumer A's signing key as the sender's key (spk) + // and Consumer A's cert as the encryption cert (ec). + return sendWithIsolatedKeys( + structure, + "/consumerA", + ConsumerConfig.CONSUMER_A.getAs2To(), + signingCertA, + decryptingKPA.getPrivate(), + signingCertA); + } + + protected HttpCoreContext sendToConsumerB(AS2MessageStructure structure) throws Exception { + // For testing, we use Consumer B's signing key as the sender's key (spk) + // and Consumer B's cert as the encryption cert (ec). + return sendWithIsolatedKeys( + structure, + "/consumerB", + ConsumerConfig.CONSUMER_B.getAs2To(), + signingCertB, + decryptingKPB.getPrivate(), + signingCertB); + } + + protected HttpCoreContext sendWithIsolatedKeys( + AS2MessageStructure structure, + String targetUri, + String as2To, + X509Certificate signingCert, + PrivateKey signingPrivateKey, + X509Certificate encryptionCert) + throws Exception { + + // This relies on the flexible send method (with requestUri, as2To, as2From params) + // being present in AS2ServerSecTestBase + return send( + structure, + targetUri, + as2To, + AS2_NAME, // AS2-From header can safely reuse the base's static AS2_NAME + new Certificate[] { signingCert }, + signingPrivateKey, + new Certificate[] { encryptionCert }); + } + + protected HttpCoreContext send( + AS2MessageStructure structure, + String requestUri, + String as2To, + String as2From, + Certificate[] sc, + PrivateKey spk, + Certificate[] ec) + throws Exception { + + // Use provided arguments or fall back to base class statics for non-overridden parts + Certificate[] signingCertificate = sc == null ? new Certificate[] { this.signingCert } : sc; + PrivateKey signingPrivateKey = spk == null ? this.signingKP.getPrivate() : spk; + Certificate[] encryptingCertificate = ec == null ? new Certificate[] { this.signingCert } : ec; + + AS2SignatureAlgorithm signingAlgorithm = structure.isSigned() ? AS2SignatureAlgorithm.SHA256WITHRSA : null; + signingCertificate = structure.isSigned() ? signingCertificate : null; + signingPrivateKey = structure.isSigned() ? signingPrivateKey : null; + AS2EncryptionAlgorithm encryptionAlgorithm = structure.isEncrypted() ? AS2EncryptionAlgorithm.AES128_CBC : null; + encryptingCertificate = structure.isEncrypted() ? encryptingCertificate : null; + AS2CompressionAlgorithm compressionAlgorithm = structure.isCompressed() ? AS2CompressionAlgorithm.ZLIB : null; + + return clientConnection().send( + EDI_MESSAGE, + requestUri, + SUBJECT, + FROM, + as2To, + as2From, + structure, + ContentType.create(AS2MediaType.APPLICATION_EDIFACT, StandardCharsets.US_ASCII), + null, + signingAlgorithm, + signingCertificate, + signingPrivateKey, + compressionAlgorithm, + DISPOSITION_NOTIFICATION_TO, + SIGNED_RECEIPT_MIC_ALGORITHMS, + encryptionAlgorithm, + encryptingCertificate, + null, + null); + } + + protected Object[] generateNewKeyPairSet(String commonName) throws Exception { + // set up our certificates + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC"); + kpg.initialize(1024, new SecureRandom()); + + String issueDN = "O=AS2 Test Issuer, C=US"; + KeyPair issueKeyPair = kpg.generateKeyPair(); + + String signingDN = "CN=" + commonName + ", E=test@example.org, O=AS2 Test, C=US"; + KeyPair signingKeyPair = kpg.generateKeyPair(); + + X509Certificate signingCert = Utils.makeCertificate( + signingKeyPair, + signingDN, + issueKeyPair, + issueDN); + + return new Object[] { issueKeyPair, signingKeyPair, signingCert }; + } + + protected PrivateKey getSigningPrivateKeyByRequestUri(String requestUri) { + for (ConsumerConfig config : ConsumerConfig.values()) { + if (config.getRequestUriPattern().equals(requestUri)) { + // Lookup the PrivateKey bound to the Registry under the signing key name + Object key = context.getRegistry().lookupByName(config.getSigningKey()); + if (key instanceof PrivateKey) { + return (PrivateKey) key; + } + // Key should always be a PrivateKey based on the AS2ServerTwoConsumerBase setup + throw new IllegalStateException("Registry entry for key '" + config.getSigningKey() + "' is not a PrivateKey."); + } + } + throw new IllegalArgumentException("No consumer configuration found for URI: " + requestUri); + } + +} diff --git a/components/camel-as2/camel-as2-component/src/test/java/org/apache/camel/component/as2/AS2ServerTwoConsumerSecEncryptedIT.java b/components/camel-as2/camel-as2-component/src/test/java/org/apache/camel/component/as2/AS2ServerTwoConsumerSecEncryptedIT.java new file mode 100644 index 0000000000000..a3ee434816689 --- /dev/null +++ b/components/camel-as2/camel-as2-component/src/test/java/org/apache/camel/component/as2/AS2ServerTwoConsumerSecEncryptedIT.java @@ -0,0 +1,70 @@ +/* + * 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.camel.component.as2; + +import java.security.PrivateKey; + +import org.apache.camel.component.as2.api.AS2MessageStructure; +import org.apache.camel.component.as2.api.entity.AS2DispositionModifier; +import org.apache.camel.component.as2.api.util.MicUtils; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Tests an AS2 server configured with a decryption key to decrypt AS2 Messages.
+ * Only messages with sufficient encryption will be processed, for instance, 'signed-encrypted', + * 'compressed-signed-encrypted', and 'signed-compressed-encrypted'.
+ * All other message structures will return an 'insufficient-message-security' error due to insufficient encryption, + * e.g. 'plain', 'plain-compressed' etc.
+ * Any decryption failure will return an 'decryption-failed' error. + */ +public class AS2ServerTwoConsumerSecEncryptedIT extends AS2ServerTwoConsumerBase { + + // verify message types that fail with insufficient security due to lack of encryption + @ParameterizedTest + @EnumSource(value = AS2MessageStructure.class, + names = { "PLAIN", "SIGNED", "PLAIN_COMPRESSED", "COMPRESSED_SIGNED", "SIGNED_COMPRESSED" }) + public void insufficientEncryptionFailureTest(AS2MessageStructure messageStructure) throws Exception { + HttpCoreContext context = sendToConsumerB(messageStructure); + verifyOkResponse(context); + verifyMdnErrorDisposition(context, AS2DispositionModifier.ERROR_INSUFFICIENT_MESSAGE_SECURITY); + } + + // verify message types that are successfully decrypted + @ParameterizedTest + @EnumSource(value = AS2MessageStructure.class, + names = { + "ENCRYPTED", "SIGNED_ENCRYPTED", "ENCRYPTED_COMPRESSED", "ENCRYPTED_COMPRESSED_SIGNED", + "ENCRYPTED_SIGNED_COMPRESSED" }) + public void successfullyProcessedTest(AS2MessageStructure messageStructure) throws Exception { + HttpCoreContext context = sendToConsumerB(messageStructure); + verifyOkResponse(context); + verifyMdnSuccessDisposition(context); + } + + // utility method to reproduce the MIC and compare against the MIC received in MDN. + @Override + protected MicUtils.ReceivedContentMic createReceivedContentMic(HttpRequest request) throws HttpException { + final String requestUri = request.getPath(); + final PrivateKey currentSigningKey = getSigningPrivateKeyByRequestUri(requestUri); + return MicUtils.createReceivedContentMic((ClassicHttpRequest) request, null, currentSigningKey); + } +} diff --git a/parent/pom.xml b/parent/pom.xml index 12160a659fa25..1784909c3d7a4 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -278,7 +278,7 @@ 2.0 5.2.0 0.8.4 - 12.0.29 + 12.0.27 10.0.20 ${jetty-version} org.eclipse.jetty @@ -381,7 +381,7 @@ 5.13.1.Final 0.4.0 5.28.1 - 4.1.128.Final + 4.1.127.Final 2.0.5 1.5.5 10.0.1 @@ -448,8 +448,8 @@ 2.0.2 0.9.0.M3 1.45.2 - 2.0.17 - 2.0.17 + 2.0.16 + 2.0.16 4.3.5 3.11.2 4.2.0 @@ -463,14 +463,14 @@ 3.9.3 1.9.5_1 2.3-groovy-4.0 - 5.2.4 - 3.4.11 - 3.2.15 + 5.2.3 + 3.4.9 + 3.2.14 3.1.3 6.2.12 3.2.7 - 6.4.12 - 4.0.16 + 6.4.11 + 4.0.15 3.0.0 3.14.9 5.1.0 @@ -490,13 +490,13 @@ 2.9.4 10.6.8 4.1.2 - 2.3.20.Final + 2.3.19.Final 2.10.1 2.0.1.Final 0.10.6 3.1 2.4.1 - 4.5.22 + 4.5.20 0.7 5.0.0 4.10.0