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"));
+ }
+}