Add support for tls-alpn-01 challenge

pull/66/head
Richard Körber 2018-04-17 00:35:26 +02:00
parent 231bad3560
commit 568438a1f3
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
13 changed files with 359 additions and 3 deletions

View File

@ -0,0 +1,72 @@
/*
* 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.challenge;
import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash;
import javax.annotation.ParametersAreNonnullByDefault;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.JSON;
/**
* Implements the {@value TYPE} challenge.
*
* @since 2.1
*/
@ParametersAreNonnullByDefault
public class TlsAlpn01Challenge extends TokenChallenge {
private static final long serialVersionUID = -5590351078176091228L;
/**
* Challenge type name: {@value}
*/
public static final String TYPE = "tls-alpn-01";
/**
* OID of the {@code acmeValidation-v1} extension.
*/
public static final String ACME_VALIDATION_V1_OID = "1.3.6.1.5.5.7.1.30.1";
/**
* {@code acme-tls/1} protocol.
*/
public static final String ACME_TLS_1_PROTOCOL = "acme-tls/1";
/**
* Creates a new generic {@link TlsAlpn01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public TlsAlpn01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Returns the value that is to be used as {@code acmeValidation-v1} extension in
* the test certificate.
*/
public byte[] getAcmeValidationV1() {
return sha256hash(getAuthorization());
}
@Override
protected boolean acceptable(String type) {
return TYPE.equals(type);
}
}

View File

@ -27,6 +27,7 @@ import org.shredzone.acme4j.Session;
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.challenge.TokenChallenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.DefaultConnection;
@ -71,6 +72,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider {
map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
map.put(Http01Challenge.TYPE, Http01Challenge::new);
map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
return Collections.unmodifiableMap(map);
}

View File

@ -29,6 +29,7 @@ import org.junit.Test;
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.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
@ -66,6 +67,11 @@ public class AuthorizationTest {
Challenge c3 = authorization.findChallenge(Dns01Challenge.TYPE);
assertThat(c3, is(notNullValue()));
assertThat(c3, is(instanceOf(Dns01Challenge.class)));
// TlsAlpn01Challenge is available
Challenge c4 = authorization.findChallenge(TlsAlpn01Challenge.TYPE);
assertThat(c4, is(notNullValue()));
assertThat(c4, is(instanceOf(TlsAlpn01Challenge.class)));
}
/**
@ -104,6 +110,7 @@ public class AuthorizationTest {
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
Authorization auth = new Authorization(login, locationUrl);
auth.update();
@ -116,7 +123,8 @@ public class AuthorizationTest {
assertThat(auth.getChallenges(), containsInAnyOrder(
provider.getChallenge(Http01Challenge.TYPE),
provider.getChallenge(Dns01Challenge.TYPE)));
provider.getChallenge(Dns01Challenge.TYPE),
provider.getChallenge(TlsAlpn01Challenge.TYPE)));
provider.close();
}
@ -191,6 +199,7 @@ public class AuthorizationTest {
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
Authorization auth = new Authorization(login, locationUrl);
@ -238,6 +247,7 @@ public class AuthorizationTest {
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
Authorization auth = new Authorization(login, locationUrl);
@ -256,7 +266,8 @@ public class AuthorizationTest {
assertThat(auth.getChallenges(), containsInAnyOrder(
provider.getChallenge(Http01Challenge.TYPE),
provider.getChallenge(Dns01Challenge.TYPE)));
provider.getChallenge(Dns01Challenge.TYPE),
provider.getChallenge(TlsAlpn01Challenge.TYPE)));
provider.close();
}
@ -286,6 +297,7 @@ public class AuthorizationTest {
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
Authorization auth = new Authorization(login, locationUrl);
auth.deactivate();
@ -302,6 +314,7 @@ public class AuthorizationTest {
provider.putTestChallenge(Http01Challenge.TYPE, Http01Challenge::new);
provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
provider.putTestChallenge(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
provider.putTestChallenge(DUPLICATE_TYPE, Challenge::new);
Authorization authorization = new Authorization(login, locationUrl);

View File

@ -0,0 +1,60 @@
/*
* 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.challenge;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
import java.io.IOException;
import org.junit.Test;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link TlsAlpn01ChallengeTest}.
*/
public class TlsAlpn01ChallengeTest {
private static final String TOKEN =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ";
private static final String KEY_AUTHORIZATION =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0";
private Login login = TestUtils.login();
/**
* Test that {@link TlsAlpn01Challenge} generates a correct authorization key.
*/
@Test
public void testTlsAlpn01Challenge() throws IOException {
TlsAlpn01Challenge challenge = new TlsAlpn01Challenge(login, getJSON("tlsAlpnChallenge"));
assertThat(challenge.getType(), is(TlsAlpn01Challenge.TYPE));
assertThat(challenge.getStatus(), is(Status.PENDING));
assertThat(challenge.getToken(), is(TOKEN));
assertThat(challenge.getAuthorization(), is(KEY_AUTHORIZATION));
assertThat(challenge.getAcmeValidationV1(), is(AcmeUtils.sha256hash(KEY_AUTHORIZATION)));
JSONBuilder response = new JSONBuilder();
challenge.prepareResponse(response);
assertThat(response.toString(), sameJSONAs("{}").allowingExtraUnexpectedFields());
}
}

View File

@ -29,6 +29,7 @@ import org.shredzone.acme4j.Session;
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.challenge.TokenChallenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.DefaultConnection;
@ -146,6 +147,10 @@ public class AbstractAcmeProviderTest {
assertThat(c3, not(nullValue()));
assertThat(c3, instanceOf(Dns01Challenge.class));
Challenge c4 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
assertThat(c4, not(nullValue()));
assertThat(c4, instanceOf(TlsAlpn01Challenge.class));
JSON json6 = new JSONBuilder()
.put("type", "foobar-01")
.put("url", "https://example.com/some/challenge")

View File

@ -10,6 +10,11 @@
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
},
{
"type": "tls-alpn-01",
"url": "https://example.com/authz/asdf/1",
"token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
},
{
"type": "duplicate-01",
"url": "https://example.com/authz/asdf/3"

View File

@ -0,0 +1,6 @@
{
"type": "tls-alpn-01",
"url": "https://example.com/acme/authz/0",
"status": "pending",
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ"
}

View File

@ -17,6 +17,12 @@
"status": "pending",
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
}
},
{
"type": "tls-alpn-01",
"status": "pending",
"url": "https://example.com/authz/asdf/2",
"token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
}
]
}

View File

@ -13,16 +13,35 @@
*/
package org.shredzone.acme4j.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Objects;
import javax.annotation.ParametersAreNonnullByDefault;
import javax.annotation.WillClose;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
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.JcaX509v3CertificateBuilder;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
/**
* Utility class offering convenience methods for certificates.
@ -32,6 +51,14 @@ import org.bouncycastle.pkcs.PKCS10CertificationRequest;
@ParametersAreNonnullByDefault
public final class CertificateUtils {
/**
* The {@code acmeValidation-v1} object identifier.
*
* @since 2.1
*/
public static final ASN1ObjectIdentifier ACME_VALIDATION_V1 =
new ASN1ObjectIdentifier(TlsAlpn01Challenge.ACME_VALIDATION_V1_OID).intern();
private CertificateUtils() {
// utility class without constructor
}
@ -54,4 +81,56 @@ public final class CertificateUtils {
}
}
/**
* Creates a self-signed {@link X509Certificate} that can be used for the
* {@link TlsAlpn01Challenge}. The certificate is valid for 7 days.
*
* @param keypair
* A domain {@link KeyPair} to be used for the challenge
* @param subject
* The subject (domain name) that is to be validated
* @param acmeValidationV1
* The value that is returned by
* {@link TlsAlpn01Challenge#getAcmeValidationV1()}
* @return Created certificate
* @since 2.1
*/
public static X509Certificate createTlsAlpn01Certificate(KeyPair keypair, String subject, byte[] acmeValidationV1)
throws IOException {
Objects.requireNonNull(keypair, "keypair");
Objects.requireNonNull(subject, "subject");
if (acmeValidationV1 == null || acmeValidationV1.length != 32) {
throw new IllegalArgumentException("Bad acmeValidationV1 parameter");
}
final long now = System.currentTimeMillis();
final String signatureAlg = "SHA256withRSA";
try {
X500Name issuer = new X500Name("CN=acme.invalid");
BigInteger serial = BigInteger.valueOf(now);
Instant notBefore = Instant.ofEpochMilli(now);
Instant notAfter = notBefore.plus(Duration.ofDays(7));
JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
issuer, serial, Date.from(notBefore), Date.from(notAfter),
issuer, keypair.getPublic());
GeneralName[] gns = new GeneralName[1];
gns[0] = new GeneralName(GeneralName.dNSName, subject);
certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns));
certBuilder.addExtension(ACME_VALIDATION_V1, true, acmeValidationV1);
JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder(signatureAlg);
byte[] cert = certBuilder.build(signerBuilder.build(keypair.getPrivate())).getEncoded();
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(cert));
} catch (CertificateException | OperatorCreationException ex) {
throw new IOException(ex);
}
}
}

View File

@ -23,9 +23,22 @@ import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.security.KeyPair;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.junit.Test;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.toolbox.AcmeUtils;
/**
* Unit tests for {@link CertificateUtils}.
@ -67,4 +80,58 @@ public class CertificateUtilsTest {
constructor.newInstance();
}
/**
* Test if
* {@link CertificateUtils#createTlsAlpn01Certificate(KeyPair, String, byte[])}
* creates a good certificate.
*/
@Test
public void testCreateTlsSni02Certificate() throws IOException, CertificateParsingException {
KeyPair keypair = KeyPairUtils.createKeyPair(2048);
String subject = "example.com";
byte[] acmeValidationV1 = AcmeUtils.sha256hash("rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ");
X509Certificate cert = CertificateUtils.createTlsAlpn01Certificate(keypair, subject, acmeValidationV1);
Instant now = Instant.now();
Instant end = now.plus(Duration.ofDays(8));
assertThat(cert, not(nullValue()));
assertThat(cert.getNotAfter(), is(greaterThan(Date.from(now))));
assertThat(cert.getNotAfter(), is(lessThan(Date.from(end))));
assertThat(cert.getNotBefore(), is(lessThanOrEqualTo(Date.from(now))));
assertThat(cert.getSubjectX500Principal().getName(), is("CN=acme.invalid"));
assertThat(getSANs(cert), contains(subject));
assertThat(cert.getCriticalExtensionOIDs(), hasItem(TlsAlpn01Challenge.ACME_VALIDATION_V1_OID));
byte[] encodedExtensionValue = cert.getExtensionValue(TlsAlpn01Challenge.ACME_VALIDATION_V1_OID);
assertThat(encodedExtensionValue, is(notNullValue()));
try (ASN1InputStream asn = new ASN1InputStream(new ByteArrayInputStream(encodedExtensionValue))) {
DEROctetString derOctetString = (DEROctetString) asn.readObject();
assertThat(derOctetString.getOctets(), is(acmeValidationV1));
}
}
/**
* Extracts all DNSName SANs from a certificate.
*
* @param cert
* {@link X509Certificate}
* @return Set of DNSName
*/
private Set<String> getSANs(X509Certificate cert) throws CertificateParsingException {
Set<String> result = new HashSet<>();
for (List<?> list : cert.getSubjectAlternativeNames()) {
if (((Number) list.get(0)).intValue() == GeneralName.dNSName) {
result.add((String) list.get(1));
}
}
return result;
}
}

View File

@ -8,3 +8,7 @@ The ACME specifications define these standard challenges:
* [http-01](./http-01.html)
* [dns-01](./dns-01.html)
_acme4j_ also supports these non-standard challenges:
* [tls-alpn-01](./tls-alpn-01.html)

View File

@ -0,0 +1,36 @@
# tls-alpn-01 Challenge
With the `tls-alpn-01` challenge, you prove to the CA that you are able to control the web server of the domain to be authorized, by letting it respond to a request with a specific self-signed cert utilizing the ALPN extension.
<div class="alert alert-info" role="alert">
This challenge is not part of the ACME specifications. It is specified [in a separate IETF document](https://tools.ietf.org/html/draft-shoemaker-acme-tls-alpn-00) and is still work in progress.
</div>
`TlsAlpn01Challenge` provides a byte array called `acmeValidationV1`:
```java
TlsSni02Challenge challenge = auth.findChallenge(TlsSni02Challenge.TYPE);
byte[] acmeValidationV1 = challenge.getAcmeValidationV1();
```
You need to create a self-signed certificate with the domain to be validated set as the only _Subject Alternative Name_. The `acmeValidationV1` must be set as octet string extension with the object id `1.3.6.1.5.5.7.1.30.1`. It is required to set this extension as critical.
After that, configure your web server so it will use this certificate on an incoming TLS request having the SNI `subject` and the ALPN protocol `acme-tls/1`.
The `TlsAlpn01Challenge` class does not generate a self-signed certificate, as it would require _Bouncy Castle_. However, there is a utility method in the _acme4j-utils_ module for this use case:
```java
String subject = auth.getDomain();
KeyPair certKeyPair = KeyPairUtils.createKeyPair(2048);
X509Certificate cert = CertificateUtils.
createTlsAlpn01Certificate(certKeyPair, subject, acmeValidationV1);
```
Now use `cert` and `certKeyPair` to let your web server respond to TLS requests containing an ALPN extension with the value `acme-tls/1` and a SNI extension containing `subject`.
<div class="alert alert-info" role="alert">
The request is sent to port 443 only. If your domain has multiple IP addresses, the CA randomly selects one of them. There is no way to choose a different port or a fixed IP address.
</div>

View File

@ -45,6 +45,7 @@
<item name="Challenges" href="challenge/index.html">
<item name="http-01" href="challenge/http-01.html"/>
<item name="dns-01" href="challenge/dns-01.html"/>
<item name="tls-alpn-01" href="challenge/tls-alpn-01.html"/>
</item>
<item name="CAs" href="ca/index.html">
<item name="Let's Encrypt" href="ca/letsencrypt.html"/>