From 8e8b5e05397aab7e9d10a2cc77919e00e91b9c79 Mon Sep 17 00:00:00 2001 From: Jake Huneau Date: Tue, 14 Jul 2026 10:23:23 -0400 Subject: [PATCH] Add mutual TLS (client certificate) authentication for outbound HTTPS Partners requiring TLS client certificate authentication on their AS2 endpoints (e.g. when migrating from Cleo Harmony) can now be sent to by configuring these partnership attributes, with fallback to properties for a global client identity: - https_client_keystore: PKCS12 (or .jks) file holding the private key and certificate chain to present - https_client_keystore_password - https_client_cert_alias: optional selection of a specific key entry when the keystore holds more than one The client key material is wired into all three SSL trust paths in HTTPUtil.buildSslFactory (system default, custom self-signed trust store and overridden SSL checks) and flows from AS2SenderModule and MDNSenderModule so messages, async MDNs and resends are all covered. Combines with existing http_user/http_password basic auth. Tested with a client-auth-required HTTPS server using certificates generated at test runtime plus a live OpenAS2 server run verifying the certificate is presented and that sends fail without it. --- RELEASE-NOTES.md | 1 + Server/src/config/partnerships.xml | 10 + .../processor/sender/AS2SenderModule.java | 4 + .../processor/sender/MDNSenderModule.java | 4 + .../main/java/org/openas2/util/HTTPUtil.java | 72 ++++++- .../openas2/util/HTTPUtilMutualTlsTest.java | 203 ++++++++++++++++++ 6 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 Server/src/test/java/org/openas2/util/HTTPUtilMutualTlsTest.java diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0fbcf849..afe6f741 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -22,6 +22,7 @@ This is a minor enhancement and bugfix release. 6. Fix start-openas2.bat for logging directory setting. 7. Add poller configuration to API command for partnership. 8. Change the IOUtil moveFile method to a more intelligent algorithm for non-homogeneous moves. +9. Add mutual TLS (client certificate) authentication for outbound HTTPS connections using the https_client_keystore, https_client_keystore_password and https_client_cert_alias partnership attributes (or properties for a global client identity). See the commented example in partnerships.xml. ## Upgrade Notes See the openAS2HowTo appendix for the general process on upgrading OpenAS2. diff --git a/Server/src/config/partnerships.xml b/Server/src/config/partnerships.xml index 7050e21f..49b43a7d 100644 --- a/Server/src/config/partnerships.xml +++ b/Server/src/config/partnerships.xml @@ -37,6 +37,16 @@ --> + diff --git a/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java b/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java index 6ce90bc8..bd293317 100644 --- a/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java +++ b/Server/src/main/java/org/openas2/processor/sender/AS2SenderModule.java @@ -191,6 +191,10 @@ private void sendMessage(String url, Message msg, MimeBodyPart securedData) thro Map httpOptions = getHttpOptions(); httpOptions.put(HTTPUtil.PARAM_HTTP_USER, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_USER)); httpOptions.put(HTTPUtil.PARAM_HTTP_PWD, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_PWD)); + // Mutual TLS client certificate - partnership attributes override global properties + httpOptions.put(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE, msg.getPartnership().getAttributeOrProperty(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE, null)); + httpOptions.put(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD, msg.getPartnership().getAttributeOrProperty(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD, null)); + httpOptions.put(HTTPUtil.PARAM_HTTPS_CLIENT_CERT_ALIAS, msg.getPartnership().getAttributeOrProperty(HTTPUtil.PARAM_HTTPS_CLIENT_CERT_ALIAS, null)); long maxSize = msg.getPartnership().getNoChunkedMaxSize(); boolean preventChunking = msg.getPartnership().isPreventChunking(false); ResponseWrapper resp = HTTPUtil.execRequest(HTTPUtil.Method.POST, url, ih, null, securedData.getInputStream(), httpOptions, maxSize, preventChunking); diff --git a/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java b/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java index ed15e7e4..bd080b56 100644 --- a/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java +++ b/Server/src/main/java/org/openas2/processor/sender/MDNSenderModule.java @@ -145,6 +145,10 @@ private boolean sendAsyncMDN(MessageMDN mdn, String url, DispositionType disposi Map httpOptions = getHttpOptions(); httpOptions.put(HTTPUtil.PARAM_HTTP_USER, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_USER)); httpOptions.put(HTTPUtil.PARAM_HTTP_PWD, msg.getPartnership().getAttribute(HTTPUtil.PARAM_HTTP_PWD)); + // Mutual TLS client certificate - partnership attributes override global properties + httpOptions.put(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE, msg.getPartnership().getAttributeOrProperty(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE, null)); + httpOptions.put(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD, msg.getPartnership().getAttributeOrProperty(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD, null)); + httpOptions.put(HTTPUtil.PARAM_HTTPS_CLIENT_CERT_ALIAS, msg.getPartnership().getAttributeOrProperty(HTTPUtil.PARAM_HTTPS_CLIENT_CERT_ALIAS, null)); // Convert the MimebodyPart to a string so we know how big it is to set Content-Length ByteArrayOutputStream dataOutputStream = new ByteArrayOutputStream(); MimeBodyPart part = mdn.getData(); diff --git a/Server/src/main/java/org/openas2/util/HTTPUtil.java b/Server/src/main/java/org/openas2/util/HTTPUtil.java index 97bcd59d..5888e5ba 100644 --- a/Server/src/main/java/org/openas2/util/HTTPUtil.java +++ b/Server/src/main/java/org/openas2/util/HTTPUtil.java @@ -65,6 +65,12 @@ public class HTTPUtil { public static final String PARAM_SOCKET_TIMEOUT = "sockettimeout"; public static final String PARAM_HTTP_USER = "http_user"; public static final String PARAM_HTTP_PWD = "http_password"; + // Client certificate (mutual TLS) authentication for outbound HTTPS connections. + // Set as partnership attributes for per-partnership identities or as properties + // in the properties file for a global client identity. + public static final String PARAM_HTTPS_CLIENT_KEYSTORE = "https_client_keystore"; + public static final String PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD = "https_client_keystore_password"; + public static final String PARAM_HTTPS_CLIENT_CERT_ALIAS = "https_client_cert_alias"; public static final String HEADER_CONTENT_TYPE = "Content-Type"; public static final String HEADER_USER_AGENT = "User-Agent"; @@ -445,16 +451,25 @@ private static SSLConnectionSocketFactory buildSslFactory(URL urlObj, Map options) throws Exception { + String ksPath = (String) options.get(PARAM_HTTPS_CLIENT_KEYSTORE); + if (ksPath == null || ksPath.length() < 1) { + return null; + } + String pwdStr = (String) options.get(PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD); + char[] password = (pwdStr == null) ? new char[0] : pwdStr.toCharArray(); + KeyStore ks = KeyStore.getInstance(ksPath.toLowerCase().endsWith(".jks") ? "JKS" : "PKCS12"); + try (FileInputStream fis = new FileInputStream(ksPath)) { + ks.load(fis, password); + } catch (IOException e) { + throw new OpenAS2Exception("Failed to load the client keystore for mutual TLS: " + ksPath, e); + } + String alias = (String) options.get(PARAM_HTTPS_CLIENT_CERT_ALIAS); + if (alias != null && alias.length() > 0) { + java.security.Key key = ks.getKey(alias, password); + if (key == null) { + throw new OpenAS2Exception("No private key found in the mutual TLS client keystore for alias \"" + alias + "\": " + ksPath); + } + KeyStore singleEntry = KeyStore.getInstance("PKCS12"); + singleEntry.load(null, null); + singleEntry.setKeyEntry(alias, key, password, ks.getCertificateChain(alias)); + ks = singleEntry; + } + if (LOG.isDebugEnabled()) { + LOG.debug("Mutual TLS client keystore loaded for outbound connection: " + ksPath + (alias == null ? "" : " :: alias: " + alias)); + } + return new ClientKeyMaterial(ks, password); + } + private static RequestBuilder getRequestBuilder(String method, URL urlObj, NameValuePair[] params, InternetHeaders headers) throws URISyntaxException { RequestBuilder req = null; diff --git a/Server/src/test/java/org/openas2/util/HTTPUtilMutualTlsTest.java b/Server/src/test/java/org/openas2/util/HTTPUtilMutualTlsTest.java new file mode 100644 index 00000000..43bf5ea9 --- /dev/null +++ b/Server/src/test/java/org/openas2/util/HTTPUtilMutualTlsTest.java @@ -0,0 +1,203 @@ +package org.openas2.util; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.io.TempDir; +import org.openas2.processor.sender.HttpSenderModule; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManagerFactory; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies that outbound HTTPS connections present a client certificate (mutual TLS) + * when a client keystore is configured in the request options, against a local HTTPS + * server that requires client authentication. + */ +@TestInstance(Lifecycle.PER_CLASS) +public class HTTPUtilMutualTlsTest { + + private static final char[] PASSWORD = "testpwd".toCharArray(); + + @TempDir + static Path tempDir; + + private HttpsServer server; + private String url; + private String clientKeystorePath; + private KeyStore serverCertTrustStore; + + @BeforeAll + public void setUp() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair serverKeys = kpg.generateKeyPair(); + KeyPair trustedClientKeys = kpg.generateKeyPair(); + KeyPair untrustedClientKeys = kpg.generateKeyPair(); + + X509Certificate serverCert = selfSignedCert(serverKeys, "CN=localhost", true); + X509Certificate trustedClientCert = selfSignedCert(trustedClientKeys, "CN=openas2-client", false); + X509Certificate untrustedClientCert = selfSignedCert(untrustedClientKeys, "CN=other-client", false); + + // Client keystore holding both keys so the alias parameter decides which one is used + KeyStore clientKs = KeyStore.getInstance("PKCS12"); + clientKs.load(null, null); + clientKs.setKeyEntry("trusted", trustedClientKeys.getPrivate(), PASSWORD, new X509Certificate[]{trustedClientCert}); + clientKs.setKeyEntry("untrusted", untrustedClientKeys.getPrivate(), PASSWORD, new X509Certificate[]{untrustedClientCert}); + clientKeystorePath = tempDir.resolve("client_certs.p12").toString(); + try (FileOutputStream fos = new FileOutputStream(clientKeystorePath)) { + clientKs.store(fos, PASSWORD); + } + + // Trust store so the HTTP client trusts the self-signed server certificate + serverCertTrustStore = KeyStore.getInstance("PKCS12"); + serverCertTrustStore.load(null, null); + serverCertTrustStore.setCertificateEntry("server", serverCert); + + // HTTPS server that requires a client certificate and only trusts the "trusted" one + KeyStore serverKs = KeyStore.getInstance("PKCS12"); + serverKs.load(null, null); + serverKs.setKeyEntry("server", serverKeys.getPrivate(), PASSWORD, new X509Certificate[]{serverCert}); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(serverKs, PASSWORD); + KeyStore clientTrust = KeyStore.getInstance("PKCS12"); + clientTrust.load(null, null); + clientTrust.setCertificateEntry("client", trustedClientCert); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(clientTrust); + SSLContext serverCtx = SSLContext.getInstance("TLS"); + serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(serverCtx) { + @Override + public void configure(HttpsParameters params) { + SSLParameters sslParams = getSSLContext().getDefaultSSLParameters(); + sslParams.setNeedClientAuth(true); + params.setSSLParameters(sslParams); + } + }); + server.createContext("/as2", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "OK".getBytes(); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.setExecutor(null); + server.start(); + url = "https://127.0.0.1:" + server.getAddress().getPort() + "/as2"; + } + + @AfterAll + public void tearDown() { + if (server != null) { + server.stop(0); + } + } + + private X509Certificate selfSignedCert(KeyPair keyPair, String dn, boolean withLocalhostSan) throws Exception { + X500Name subject = new X500Name(dn); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), + new Date(System.currentTimeMillis() - 3600_000L), + new Date(System.currentTimeMillis() + 24 * 3600_000L), + subject, keyPair.getPublic()); + if (withLocalhostSan) { + builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(new GeneralName[]{ + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1")})); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + private Map baseOptions() { + Map options = new HashMap(); + options.put(HTTPUtil.PARAM_CONNECT_TIMEOUT, "10000"); + options.put(HTTPUtil.PARAM_SOCKET_TIMEOUT, "10000"); + // Trust the self-signed server certificate via the custom trust store mechanism + options.put(HttpSenderModule.PARAM_CUSTOM_SSL_TRUST_STORE, serverCertTrustStore); + return options; + } + + private Map mtlsOptions(String alias) { + Map options = baseOptions(); + options.put(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE, clientKeystorePath); + options.put(HTTPUtil.PARAM_HTTPS_CLIENT_KEYSTORE_PASSWORD, new String(PASSWORD)); + if (alias != null) { + options.put(HTTPUtil.PARAM_HTTPS_CLIENT_CERT_ALIAS, alias); + } + return options; + } + + @Test + public void presentsClientCertificateWhenConfigured() throws Exception { + ResponseWrapper resp = HTTPUtil.execRequest(HTTPUtil.Method.GET, url, null, null, null, mtlsOptions("trusted"), 0L, false); + assertEquals(200, resp.getStatusCode()); + } + + @Test + public void presentsClientCertificateWithOverriddenSslChecks() throws Exception { + Map options = mtlsOptions("trusted"); + options.put(HTTPUtil.HTTP_PROP_OVERRIDE_SSL_CHECKS, "true"); + ResponseWrapper resp = HTTPUtil.execRequest(HTTPUtil.Method.GET, url, null, null, null, options, 0L, false); + assertEquals(200, resp.getStatusCode()); + } + + @Test + public void handshakeFailsWithoutClientCertificate() { + assertThrows(IOException.class, () -> HTTPUtil.execRequest(HTTPUtil.Method.GET, url, null, null, null, baseOptions(), 0L, false)); + } + + @Test + public void aliasSelectsTheKeyPresentedToTheServer() { + // The untrusted key is a valid entry in the same keystore but the server rejects it, + // proving the alias parameter controls which certificate is presented + assertThrows(IOException.class, () -> HTTPUtil.execRequest(HTTPUtil.Method.GET, url, null, null, null, mtlsOptions("untrusted"), 0L, false)); + } + + @Test + public void unknownAliasFailsWithClearError() { + Exception e = assertThrows(Exception.class, () -> HTTPUtil.execRequest(HTTPUtil.Method.GET, url, null, null, null, mtlsOptions("no-such-alias"), 0L, false)); + org.hamcrest.MatcherAssert.assertThat(e.getMessage(), org.hamcrest.CoreMatchers.containsString("no-such-alias")); + } +}