From a089a307ffdfe9988c3ed3c0e0b4e950f985b9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Tue, 17 Apr 2018 22:13:05 +0200 Subject: [PATCH] Add tls-alpn test server and integration test for Pebble --- acme4j-it/pom.xml | 15 +- .../org/shredzone/acme4j/it/BammBamm.java | 21 +- .../shredzone/acme4j/it/BammBammClient.java | 54 ++++ .../shredzone/acme4j/it/TlsAlpnHandler.java | 80 ++++++ .../acme4j/it/server/TlsAlpnServer.java | 262 ++++++++++++++++++ .../src/main/resources/bammbamm.properties | 3 + .../shredzone/acme4j/it/pebble/OrderIT.java | 28 ++ acme4j-it/src/test/pebble/pebble-config.json | 3 +- src/site/markdown/development/testing.md | 9 +- 9 files changed, 466 insertions(+), 9 deletions(-) create mode 100644 acme4j-it/src/main/java/org/shredzone/acme4j/it/TlsAlpnHandler.java create mode 100644 acme4j-it/src/main/java/org/shredzone/acme4j/it/server/TlsAlpnServer.java diff --git a/acme4j-it/pom.xml b/acme4j-it/pom.xml index 0f31c5c4..818e7789 100644 --- a/acme4j-it/pom.xml +++ b/acme4j-it/pom.xml @@ -29,6 +29,11 @@ acme4j Integration Tests + + + 8u162 + 8.1.12.v20180117 + true src/main/java/org/shredzone/acme4j/** @@ -175,15 +180,16 @@ acme4j/bammbamm:${project.version} - openjdk:8-jre + openjdk:${alpn-java.version}-jre 53/udp + 5001 5002 14001 - java -cp "/maven/*" org.shredzone.acme4j.it.BammBamm + java -Xbootclasspath/p:/maven/alpn-boot-${alpn-boot.version}.jar -cp "/maven/*" org.shredzone.acme4j.it.BammBamm @@ -220,6 +226,11 @@ ${project.version} + + org.mortbay.jetty.alpn + alpn-boot + ${alpn-boot.version} + 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.

## 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.