org.nanohttpd
nanohttpd
diff --git a/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBamm.java b/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBamm.java
index 2f402028..803f6d3c 100644
--- a/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBamm.java
+++ b/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBamm.java
@@ -21,6 +21,7 @@ import javax.annotation.ParametersAreNonnullByDefault;
import org.shredzone.acme4j.it.server.DnsServer;
import org.shredzone.acme4j.it.server.HttpServer;
+import org.shredzone.acme4j.it.server.TlsAlpnServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,8 +29,8 @@ import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.router.RouterNanoHTTPD;
/**
- * A mock server to test Pebble. It provides a HTTP server and DNS server. The servers can
- * be configured remotely via simple HTTP POST requests.
+ * A mock server to test Pebble. It provides a HTTP server, TLS-ALPN server and DNS
+ * server. The servers can be configured remotely via simple HTTP POST requests.
*
* WARNING: This is a very simple server that is only meant to be used for
* integration tests. Do not use in the outside world!
@@ -43,18 +44,22 @@ public class BammBamm {
private final int appPort;
private final int httpPort;
private final int dnsPort;
+ private final int tlsAlpnPort;
private final AppServer appServer;
private final DnsServer dnsServer;
private final HttpServer httpServer;
+ private final TlsAlpnServer tlsAlpnServer;
private BammBamm() {
ResourceBundle bundle = ResourceBundle.getBundle("bammbamm");
appPort = Integer.parseInt(bundle.getString("app.port"));
dnsPort = Integer.parseInt(bundle.getString("dns.port"));
httpPort = Integer.parseInt(bundle.getString("http.port"));
+ tlsAlpnPort = Integer.parseInt(bundle.getString("tlsAlpn.port"));
dnsServer = new DnsServer();
httpServer = new HttpServer();
+ tlsAlpnServer = new TlsAlpnServer();
appServer = new AppServer(appPort);
}
@@ -81,12 +86,20 @@ public class BammBamm {
return httpServer;
}
+ /**
+ * Returns the {@link TlsAlpnServer} instance.
+ */
+ public TlsAlpnServer getTlsAlpnServer() {
+ return tlsAlpnServer;
+ }
+
/**
* Starts the servers.
*/
public void start() {
dnsServer.start(dnsPort);
httpServer.start(httpPort);
+ tlsAlpnServer.start(tlsAlpnPort);
try {
appServer.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true);
@@ -102,6 +115,7 @@ public class BammBamm {
*/
public void stop() {
appServer.stop();
+ tlsAlpnServer.stop();
httpServer.stop();
dnsServer.stop();
@@ -123,6 +137,9 @@ public class BammBamm {
addRoute(HttpHandler.ADD, HttpHandler.Add.class);
addRoute(HttpHandler.REMOVE, HttpHandler.Remove.class);
+
+ addRoute(TlsAlpnHandler.ADD, TlsAlpnHandler.Add.class);
+ addRoute(TlsAlpnHandler.REMOVE, TlsAlpnHandler.Remove.class);
}
}
diff --git a/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBammClient.java b/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBammClient.java
index a4e13a53..af299d8d 100644
--- a/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBammClient.java
+++ b/acme4j-it/src/main/java/org/shredzone/acme4j/it/BammBammClient.java
@@ -17,7 +17,12 @@ import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.Charset;
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Base64.Encoder;
import java.util.List;
import javax.annotation.ParametersAreNonnullByDefault;
@@ -134,6 +139,41 @@ public class BammBammClient {
.submit();
}
+ /**
+ * Adds a certificate for TLS-ALPN tests.
+ *
+ * @param alias
+ * An alias to be used for removal, for example the domain name being
+ * validated.
+ * @param privateKey
+ * {@link PrivateKey} of the certificate
+ * @param cert
+ * {@link X509Certificate} containing the domain name to respond to
+ */
+ public void tlsAlpnAddCertificate(String alias, PrivateKey privateKey, X509Certificate cert) throws IOException {
+ try {
+ createRequest(TlsAlpnHandler.ADD)
+ .arg(":alias", alias)
+ .param("privateKey", privateKey.getEncoded())
+ .param("cert", cert.getEncoded())
+ .submit();
+ } catch (CertificateEncodingException ex) {
+ throw new IOException(ex);
+ }
+ }
+
+ /**
+ * Removes a certificate.
+ *
+ * @param alias
+ * Certificate alias to remove
+ */
+ public void tlsAlpnRemoveCertificate(String alias) throws IOException {
+ createRequest(TlsAlpnHandler.REMOVE)
+ .arg(":alias", alias)
+ .submit();
+ }
+
/**
* Creates a new {@link Request} object.
*
@@ -151,6 +191,7 @@ public class BammBammClient {
@ParametersAreNonnullByDefault
private static class Request {
private static final HttpClient CLIENT = HttpClients.createDefault();
+ private static final Encoder BASE64 = Base64.getEncoder();
private static final Charset UTF8 = Charset.forName("utf-8");
private final List params = new ArrayList<>();
@@ -202,6 +243,19 @@ public class BammBammClient {
return this;
}
+ /**
+ * Adds a binary form parameter. It will be sent in the request body.
+ *
+ * @param key
+ * Parameter name
+ * @param value
+ * Parameter value. It will be Base64 encoded.
+ * @return itself
+ */
+ public Request param(String key, byte[] value) {
+ return param(key, BASE64.encodeToString(value));
+ }
+
/**
* Submits the POST request.
*/
diff --git a/acme4j-it/src/main/java/org/shredzone/acme4j/it/TlsAlpnHandler.java b/acme4j-it/src/main/java/org/shredzone/acme4j/it/TlsAlpnHandler.java
new file mode 100644
index 00000000..3f298a84
--- /dev/null
+++ b/acme4j-it/src/main/java/org/shredzone/acme4j/it/TlsAlpnHandler.java
@@ -0,0 +1,80 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2018 Richard "Shred" Körber
+ * http://acme4j.shredzone.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ */
+package org.shredzone.acme4j.it;
+
+import java.io.ByteArrayInputStream;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Base64;
+import java.util.Base64.Decoder;
+import java.util.Map;
+
+import org.shredzone.acme4j.it.server.TlsAlpnServer;
+
+import fi.iki.elonen.NanoHTTPD.IHTTPSession;
+
+/**
+ * Request handler for all {@code tls-alpn-01} related requests.
+ */
+public final class TlsAlpnHandler {
+
+ public static final String ADD = "/tlsalpn/add/:alias";
+ public static final String REMOVE = "/tlsalpn/remove/:alias";
+
+ private TlsAlpnHandler() {
+ // this class cannot be instanciated.
+ }
+
+ /**
+ * Adds an TLS-ALPN certificate.
+ */
+ public static class Add extends AbstractResponder {
+ @Override
+ public void handle(Map urlParams, IHTTPSession session) throws Exception {
+ String alias = urlParams.get("alias");
+ String privateKeyEncoded = session.getParameters().get("privateKey").get(0);
+ String certEncoded = session.getParameters().get("cert").get(0);
+
+ Decoder base64 = Base64.getDecoder();
+
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(
+ base64.decode(privateKeyEncoded)));
+
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ X509Certificate cert = (X509Certificate) cf.generateCertificate(
+ new ByteArrayInputStream(base64.decode(certEncoded)));
+
+ TlsAlpnServer server = BammBamm.instance().getTlsAlpnServer();
+ server.addCertificate(alias, privateKey, cert);
+ }
+ }
+
+ /**
+ * Removes an TLS-ALPN certificate.
+ */
+ public static class Remove extends AbstractResponder {
+ @Override
+ public void handle(Map urlParams, IHTTPSession session) throws Exception {
+ String alias = urlParams.get("alias");
+
+ TlsAlpnServer server = BammBamm.instance().getTlsAlpnServer();
+ server.removeCertificate(alias);
+ }
+ }
+
+}
diff --git a/acme4j-it/src/main/java/org/shredzone/acme4j/it/server/TlsAlpnServer.java b/acme4j-it/src/main/java/org/shredzone/acme4j/it/server/TlsAlpnServer.java
new file mode 100644
index 00000000..c08d8b09
--- /dev/null
+++ b/acme4j-it/src/main/java/org/shredzone/acme4j/it/server/TlsAlpnServer.java
@@ -0,0 +1,262 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2018 Richard "Shred" Körber
+ * http://acme4j.shredzone.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ */
+package org.shredzone.acme4j.it.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.KeyManagementException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.net.ssl.KeyManager;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLServerSocketFactory;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.eclipse.jetty.alpn.ALPN;
+import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A very simple TLS-ALPN server. It waits for a connection and performs a TLS handshake
+ * returning the matching certificate to the requested domain.
+ *
+ * This server can be used to validate {@code tls-alpn-01} challenges.
+ */
+public class TlsAlpnServer {
+ private static final Logger LOG = LoggerFactory.getLogger(TlsAlpnServer.class);
+ private static final char[] PASSWORD = "shibboleet".toCharArray();
+
+ private KeyStore keyStore = null;
+ private Thread thread = null;
+ private volatile boolean running = false;
+ private volatile boolean listening = false;
+
+ /**
+ * Adds a certificate to the set of known certificates.
+ *
+ * The certificate's SAN is used for SNI.
+ *
+ * @param alias
+ * Internal alias
+ * @param privateKey
+ * Private key to be used with this certificate
+ * @param cert
+ * {@link X509Certificate} to be added
+ */
+ public void addCertificate(String alias, PrivateKey privateKey, X509Certificate cert) {
+ initKeyStore();
+
+ try {
+ keyStore.setKeyEntry(alias, privateKey, PASSWORD, new Certificate[] {cert});
+ } catch (KeyStoreException ex) {
+ throw new IllegalArgumentException("Failed to add certificate " + alias, ex);
+ }
+ }
+
+ /**
+ * Removes a certificate.
+ *
+ * @param alias
+ * Internal alias of the certificate to remove
+ */
+ public void removeCertificate(String alias) {
+ initKeyStore();
+
+ try {
+ keyStore.deleteEntry(alias);
+ } catch (KeyStoreException ex) {
+ throw new IllegalArgumentException("Failed to remove certificate " + alias, ex);
+ }
+ }
+
+ /**
+ * Starts the TlsAlpn server.
+ *
+ * @param port
+ * Port to listen to
+ */
+ public void start(int port) {
+ if (thread != null) {
+ throw new IllegalStateException("Server is already running");
+ }
+
+ running = true;
+ thread = new Thread(() -> serve(port));
+ thread.setName("tls-alpn server");
+ thread.start();
+ LOG.info("tls-alpn server listening at port {}", port);
+ }
+
+ /**
+ * Stops the TlsAlpn server.
+ */
+ public void stop() {
+ if (thread != null) {
+ running = false;
+ thread.interrupt();
+ thread = null;
+ }
+ }
+
+ /**
+ * Checks if the server was started up and is listening to connections.
+ */
+ public boolean isListening() {
+ return listening;
+ }
+
+ /**
+ * Opens an SSL server socket and processes incoming requests.
+ *
+ * @param port
+ * Port to listen at
+ */
+ private void serve(int port) {
+ SSLContext sslContext = createSSLContext();
+ SSLServerSocketFactory sslServerSocketFactory = sslContext.getServerSocketFactory();
+
+ try (SSLServerSocket sslServerSocket = (SSLServerSocket)
+ sslServerSocketFactory.createServerSocket(port)){
+ listening = true;
+ while (running) {
+ process(sslServerSocket);
+ }
+ } catch (IOException ex) {
+ LOG.error("Failed to create socket on port {}", port, ex);
+ }
+
+ listening = false;
+ }
+
+ /**
+ * Accept and process an incoming request. Only the TLS handshake is used here.
+ * Incoming data is just consumed, and the socket is closed after that.
+ *
+ * @param sslServerSocket
+ * {@link SSLServerSocket} to accept connections from
+ */
+ private void process(SSLServerSocket sslServerSocket) {
+ try (SSLSocket sslSocket = (SSLSocket) sslServerSocket.accept()) {
+ ALPN.put(sslSocket, new ALPN.ServerProvider() {
+ @Override
+ public void unsupported() {
+ ALPN.remove(sslSocket);
+ }
+
+ @Override
+ public String select(List protocols) {
+ ALPN.remove(sslSocket);
+ if (protocols.contains(TlsAlpn01Challenge.ACME_TLS_1_PROTOCOL)) {
+ return TlsAlpn01Challenge.ACME_TLS_1_PROTOCOL;
+ } else {
+ return null;
+ }
+ }
+ });
+
+ sslSocket.setEnabledCipherSuites(sslSocket.getSupportedCipherSuites());
+ sslSocket.startHandshake();
+
+ SSLSession sslSession = sslSocket.getSession();
+ X509Certificate cert = (X509Certificate) sslSession.getLocalCertificates()[0];
+ LOG.info("tls-alpn: {}", domainsToString(cert));
+
+ try (InputStream in = sslSocket.getInputStream()) {
+ while (in.read() >= 0); //NOSONAR: intentional empty statement
+ }
+ } catch (Exception ex) {
+ LOG.error("Failed to process request", ex);
+ }
+ }
+
+ /**
+ * Lazily initializes the {@link KeyStore} instance to be used. The key store is empty
+ * after initialization.
+ */
+ private void initKeyStore() {
+ if (keyStore == null) {
+ try {
+ keyStore = KeyStore.getInstance("JKS");
+ keyStore.load(null, null);
+ } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException
+ | IOException ex) {
+ throw new IllegalStateException("Failed to create key store", ex);
+ }
+ }
+ }
+
+ /**
+ * Creates a {@link SSLContext} that uses the internal {@link KeyStore} for key and
+ * trust management.
+ *
+ * @return {@link SSLContext} instance
+ */
+ private SSLContext createSSLContext() {
+ initKeyStore();
+
+ try {
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("NewSunX509");
+ keyManagerFactory.init(keyStore, PASSWORD);
+ KeyManager[] km = keyManagerFactory.getKeyManagers();
+
+ TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("SunX509");
+ trustManagerFactory.init(keyStore);
+ TrustManager[] tm = trustManagerFactory.getTrustManagers();
+
+ SSLContext sslContext = SSLContext.getInstance("TLSv1");
+ sslContext.init(km, tm, null);
+
+ return sslContext;
+ } catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException
+ | UnrecoverableKeyException ex) {
+ throw new IllegalStateException("Could not create SSLContext", ex);
+ }
+ }
+
+ /**
+ * Extracts all SANs of the given certificate and returns them as a string.
+ *
+ * @param cert
+ * {@link X509Certificate} to read the SANs from
+ * @return String of all SAN names joined together and separated by comma
+ */
+ private String domainsToString(X509Certificate cert) {
+ try {
+ return cert.getSubjectAlternativeNames().stream()
+ .filter(c -> ((Number) c.get(0)).intValue() == GeneralName.dNSName)
+ .map(c -> (String) c.get(1))
+ .collect(Collectors.joining(", "));
+ } catch (CertificateParsingException ex) {
+ throw new IllegalArgumentException("bad certificate", ex);
+ }
+ }
+
+}
diff --git a/acme4j-it/src/main/resources/bammbamm.properties b/acme4j-it/src/main/resources/bammbamm.properties
index 4c22d695..1473fd6f 100644
--- a/acme4j-it/src/main/resources/bammbamm.properties
+++ b/acme4j-it/src/main/resources/bammbamm.properties
@@ -7,3 +7,6 @@ dns.port = 53
# HTTP server port
http.port = 5002
+
+# TLS-ALPN server port
+tlsAlpn.port = 5001
diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java
index c5e29095..9a1270ca 100644
--- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java
+++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java
@@ -35,8 +35,10 @@ import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
+import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.it.BammBammClient;
import org.shredzone.acme4j.util.CSRBuilder;
+import org.shredzone.acme4j.util.CertificateUtils;
/**
* Tests a complete certificate order with different challenges.
@@ -87,6 +89,32 @@ public class OrderIT extends PebbleITBase {
});
}
+ /**
+ * Test if a certificate can be ordered via tns-alpn-01 challenge.
+ */
+ @Test
+ public void testTlsAlpnValidation() throws Exception {
+ orderCertificate(TEST_DOMAIN, auth -> {
+ BammBammClient client = getBammBammClient();
+
+ TlsAlpn01Challenge challenge = auth.findChallenge(TlsAlpn01Challenge.TYPE);
+ assertThat(challenge, is(notNullValue()));
+
+ KeyPair challengeKey = createKeyPair();
+
+ X509Certificate cert = CertificateUtils.createTlsAlpn01Certificate(
+ challengeKey, auth.getDomain(), challenge.getAcmeValidationV1());
+
+ client.dnsAddARecord(TEST_DOMAIN, getBammBammHostname());
+ client.tlsAlpnAddCertificate(auth.getDomain(), challengeKey.getPrivate(), cert);
+
+ cleanup(() -> client.dnsRemoveARecord(TEST_DOMAIN));
+ cleanup(() -> client.tlsAlpnRemoveCertificate(auth.getDomain()));
+
+ return challenge;
+ });
+ }
+
/**
* Runs the complete process of ordering a certificate.
*
diff --git a/acme4j-it/src/test/pebble/pebble-config.json b/acme4j-it/src/test/pebble/pebble-config.json
index 7e7447d7..a2d7f7b6 100644
--- a/acme4j-it/src/test/pebble/pebble-config.json
+++ b/acme4j-it/src/test/pebble/pebble-config.json
@@ -3,6 +3,7 @@
"listenAddress": "0.0.0.0:14000",
"certificate": "/go/src/github.com/letsencrypt/pebble/test/certs/localhost/cert.pem",
"privateKey": "/go/src/github.com/letsencrypt/pebble/test/certs/localhost/key.pem",
- "httpPort": 5002
+ "httpPort": 5002,
+ "tlsPort": 5001
}
}
diff --git a/src/site/markdown/development/testing.md b/src/site/markdown/development/testing.md
index cc97f2d7..bcf56b45 100644
--- a/src/site/markdown/development/testing.md
+++ b/src/site/markdown/development/testing.md
@@ -14,15 +14,16 @@ The `acme4j-it` module contains a small and very simple test server called _Bamm
* 14001: Provides a simple REST-like interface for adding and removing challenge tokens and DNS records. This port is exposed.
* 53 (UDP): A simple DNS server for resolving test domain names, and providing `TXT` records for `dns-01` challenges.
-* 5002: A simple HTTP server that responses with tokens for `http-01` challenges.
+* 5001: A simple TLS-ALPN server that responds with certificates for `tls-alpn-01` challenges.
+* 5002: A simple HTTP server that responds with tokens for `http-01` challenges.
-To run this server, you can use the Docker image mentioned above. You could also run the server directly, but since the DNS server is listening on a privileged port, it would need to be reconfigured first.
+To run this server, you can use the Docker image mentioned above. You could also run the server directly, but since the DNS server is listening on a privileged port, it would need to be reconfigured first. Also, if you run BammBamm with OpenJDK 8, make sure to [add the correct `alpn-boot.jar` version to your boot classpath](https://www.eclipse.org/jetty/documentation/9.4.x/alpn-chapter.html#alpn-versions) in order to use the TLS-ALPN server.
The `BammBammClient` class can be used to set the challenge responses and DNS records via the REST interface on port 14001.
-Do not use _Bammbamm_ in production environments! It has its main focus on simplicity, and is only meant as a server for integration test purposes. It is neither hardened, nor feature complete.
+Do not use _BammBamm_ in production environments! It has its main focus on simplicity, and is only meant as a server for integration test purposes. It is neither hardened, nor feature complete.
## Boulder
@@ -39,4 +40,4 @@ Now set up a Docker instance of Boulder. Follow the instructions in the [Boulder
The Boulder integration tests can now be run with `mvn -P boulder verify`.
-For a local Boulder installation, just make sure that `FAKE_DNS` is set to `127.0.0.1`. You'll also need to expose the port 5002 of _BammBamm_ by changing the `acme4j-it/pom.xml` accordingly.
+For a local Boulder installation, just make sure that `FAKE_DNS` is set to `127.0.0.1`. You'll also need to expose the ports 5001 and 5002 of _BammBamm_ by changing the `acme4j-it/pom.xml` accordingly.