Add tls-alpn test server and integration test for Pebble

pull/66/head
Richard Körber 2018-04-17 22:13:05 +02:00
parent 677fce8f5f
commit a089a307ff
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
9 changed files with 466 additions and 9 deletions

View File

@ -29,6 +29,11 @@
<description>acme4j Integration Tests</description>
<properties>
<!-- alpn-boot version must match the java version used in bammbamm -->
<!-- see: https://www.eclipse.org/jetty/documentation/9.4.x/alpn-chapter.html#alpn-versions -->
<alpn-java.version>8u162</alpn-java.version>
<alpn-boot.version>8.1.12.v20180117</alpn-boot.version>
<skipITs>true</skipITs>
<sonar.coverage.exclusions>src/main/java/org/shredzone/acme4j/**</sonar.coverage.exclusions>
</properties>
@ -175,15 +180,16 @@
<name>acme4j/bammbamm:${project.version}</name>
<build>
<from>openjdk:8-jre</from>
<from>openjdk:${alpn-java.version}-jre</from>
<ports>
<port>53/udp</port>
<port>5001</port>
<port>5002</port>
<port>14001</port>
</ports>
<cmd>
<shell>
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
</shell>
</cmd>
<assembly>
@ -220,6 +226,11 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.mortbay.jetty.alpn</groupId>
<artifactId>alpn-boot</artifactId>
<version>${alpn-boot.version}</version>
</dependency>
<dependency>
<groupId>org.nanohttpd</groupId>
<artifactId>nanohttpd</artifactId>

View File

@ -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.
* <p>
* <em>WARNING:</em> 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);
}
}

View File

@ -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<NameValuePair> 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.
*/

View File

@ -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<String, String> 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<String, String> urlParams, IHTTPSession session) throws Exception {
String alias = urlParams.get("alias");
TlsAlpnServer server = BammBamm.instance().getTlsAlpnServer();
server.removeCertificate(alias);
}
}
}

View File

@ -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.
* <p>
* 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.
* <p>
* 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<String> 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);
}
}
}

View File

@ -7,3 +7,6 @@ dns.port = 53
# HTTP server port
http.port = 5002
# TLS-ALPN server port
tlsAlpn.port = 5001

View File

@ -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.
*

View File

@ -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
}
}

View File

@ -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.
<div class="alert alert-danger" role="alert">
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.
</div>
## 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.