Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions Server/src/config/partnerships.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@
<attribute name="as2_receipt_option" value="$properties.as2_async_mdn_url$"/>
-->

<!-- If the partner's HTTPS endpoint requires a client certificate (mutual TLS), configure
the keystore holding the private key and certificate to present. The keystore type is
PKCS12 unless the file name ends in ".jks". The alias is optional and selects a specific
key entry when the keystore holds more than one; the key must use the keystore password.
These can also be set as properties in the properties file for a global client identity
with partnership attributes taking precedence.
<attribute name="https_client_keystore" value="/path/to/client_certs.p12"/>
<attribute name="https_client_keystore_password" value="mypassword"/>
<attribute name="https_client_cert_alias" value="mycompany_client"/>
-->
<attribute name="encrypt" value="3DES"/>
<attribute name="sign" value="SHA-256"/>
<attribute name="resend_max_retries" value="3"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ private void sendMessage(String url, Message msg, MimeBodyPart securedData) thro
Map<String, Object> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ private boolean sendAsyncMDN(MessageMDN mdn, String url, DispositionType disposi
Map<String, Object> 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();
Expand Down
72 changes: 70 additions & 2 deletions Server/src/main/java/org/openas2/util/HTTPUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -445,16 +451,25 @@ private static SSLConnectionSocketFactory buildSslFactory(URL urlObj, Map<String
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
SSLContext sslcontext;

// Client certificate for mutual TLS if configured for this connection
ClientKeyMaterial clientKeyMaterial = getClientKeyMaterial(options);

if (selfsignedCertsKeystore != null) {
try {
// Trust own CA and all self-signed certs
sslcontext = SSLContexts.custom().loadTrustMaterial(selfsignedCertsKeystore, new TrustSelfSignedStrategy()).build();
org.apache.http.ssl.SSLContextBuilder sslContextBuilder = SSLContexts.custom().loadTrustMaterial(selfsignedCertsKeystore, new TrustSelfSignedStrategy());
if (clientKeyMaterial != null) {
sslContextBuilder.loadKeyMaterial(clientKeyMaterial.keyStore, clientKeyMaterial.password);
}
sslcontext = sslContextBuilder.build();
if (LOG.isTraceEnabled()) {
LOG.trace("SSL context built using self signed trust store...");
}
} catch (Exception e) {
throw new OpenAS2Exception("Attempted connection using self-signed manager failed connecting to : " + urlObj.toString(), e);
}
} else if (clientKeyMaterial != null) {
sslcontext = SSLContexts.custom().loadKeyMaterial(clientKeyMaterial.keyStore, clientKeyMaterial.password).build();
} else {
sslcontext = SSLContexts.createSystemDefault();
}
Expand Down Expand Up @@ -500,14 +515,67 @@ public boolean verify(String hostname, SSLSession session) {
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory
.getDefaultAlgorithm());
kmf.init(selfsignedCertsKeystore, null);
if (clientKeyMaterial != null) {
kmf.init(clientKeyMaterial.keyStore, clientKeyMaterial.password);
} else {
kmf.init(selfsignedCertsKeystore, null);
}
// Now add the custom trust manager to the SSL context
sslcontext.init(kmf.getKeyManagers(), new TrustManager[]{tm}, null);
}
SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslcontext, null, null, hnv);
return sslsf;
}

private static class ClientKeyMaterial {
private final KeyStore keyStore;
private final char[] password;

private ClientKeyMaterial(KeyStore keyStore, char[] password) {
this.keyStore = keyStore;
this.password = password;
}
}

/**
* Loads the keystore holding the client certificate to present for mutual TLS if one
* is configured in the options. Keystore type is PKCS12 unless the file name ends in
* ".jks". If an alias is configured the returned keystore is reduced to just that key
* entry so the TLS layer cannot pick a different one; the key must use the same
* password as the keystore.
*
* @return the keystore and its password, or null when no client keystore is configured
*/
private static ClientKeyMaterial getClientKeyMaterial(Map<String, Object> 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;
Expand Down
203 changes: 203 additions & 0 deletions Server/src/test/java/org/openas2/util/HTTPUtilMutualTlsTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> baseOptions() {
Map<String, Object> options = new HashMap<String, Object>();
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<String, Object> mtlsOptions(String alias) {
Map<String, Object> 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<String, Object> 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"));
}
}