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