From 8deceb473c72cf300ae83d6cfc769ec47edc32ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Sat, 19 Mar 2016 15:42:22 +0100 Subject: [PATCH] Add support for tls-sni-02 --- .../acme4j/challenge/TlsSni01Challenge.java | 4 + .../acme4j/challenge/TlsSni02Challenge.java | 98 +++++++++++++++++++ .../provider/AbstractAcmeClientProvider.java | 3 + .../shredzone/acme4j/AuthorizationTest.java | 20 ++-- ...geTest.java => TlsSni01ChallengeTest.java} | 3 +- .../challenge/TlsSni02ChallengeTest.java | 79 +++++++++++++++ .../AbstractAcmeClientProviderTest.java | 15 ++- .../src/test/resources/json.properties | 7 ++ .../java/org/shredzone/acme4j/ClientTest.java | 6 +- .../acme4j/util/CertificateUtils.java | 52 +++++++++- .../acme4j/util/CertificateUtilsTest.java | 25 +++++ src/site/markdown/challenge/index.md | 3 +- src/site/markdown/challenge/tls-sni-01.md | 2 + src/site/markdown/challenge/tls-sni-02.md | 45 +++++++++ src/site/site.xml | 1 + 15 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni02Challenge.java rename acme4j-client/src/test/java/org/shredzone/acme4j/challenge/{TlsSniChallengeTest.java => TlsSni01ChallengeTest.java} (95%) create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java create mode 100644 src/site/markdown/challenge/tls-sni-02.md diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni01Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni01Challenge.java index afa41988..daf150f5 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni01Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni01Challenge.java @@ -23,8 +23,12 @@ import org.shredzone.acme4j.exception.AcmeProtocolException; /** * Implements the {@value TYPE} challenge. * + * @deprecated Use {@link TlsSni02Challenge} if supported by the CA. This challenge will + * be removed as soon as Let's Encrypt removes support for + * {@link TlsSni01Challenge}. * @author Richard "Shred" Körber */ +@Deprecated public class TlsSni01Challenge extends GenericTokenChallenge { private static final long serialVersionUID = 7370329525205430573L; private static final char[] HEX = "0123456789abcdef".toCharArray(); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni02Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni02Challenge.java new file mode 100644 index 00000000..1e2d3486 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSni02Challenge.java @@ -0,0 +1,98 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2016 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 java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.shredzone.acme4j.Registration; +import org.shredzone.acme4j.exception.AcmeProtocolException; + +/** + * Implements the {@value TYPE} challenge. + * + * @author Richard "Shred" Körber + */ +public class TlsSni02Challenge extends GenericTokenChallenge { + private static final long serialVersionUID = 8921833167878544518L; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + /** + * Challenge type name: {@value} + */ + public static final String TYPE = "tls-sni-02"; + + private String subject; + private String sanB; + + /** + * Returns the subject, which is to be used as "SAN-A" in a self-signed certificate. + * The CA will send the SNI request against this domain. + */ + public String getSubject() { + assertIsAuthorized(); + return subject; + } + + /** + * Returns the key authorization, which is to be used as "SAN-B" in a self-signed + * certificate. + */ + public String getSanB() { + assertIsAuthorized(); + return sanB; + } + + @Override + public void authorize(Registration registration) { + super.authorize(registration); + + String tokenHash = computeHash(getToken()); + subject = tokenHash.substring(0, 32) + '.' + tokenHash.substring(32) + ".token.acme.invalid"; + + String kaHash = computeHash(getAuthorization()); + sanB = kaHash.substring(0, 32) + '.' + kaHash.substring(32) + ".ka.acme.invalid"; + } + + @Override + protected boolean acceptable(String type) { + return TYPE.equals(type); + } + + /** + * Computes a hash according to the specifications. + * + * @param z + * Value to be hashed + * @return Hash + */ + private String computeHash(String z) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(z.getBytes("UTF-8")); + byte[] raw = md.digest(); + char[] result = new char[raw.length * 2]; + for (int ix = 0; ix < raw.length; ix++) { + int val = raw[ix] & 0xFF; + result[ix * 2] = HEX[val >>> 4]; + result[ix * 2 + 1] = HEX[val & 0x0F]; + } + return new String(result); + } catch (NoSuchAlgorithmException | UnsupportedEncodingException ex) { + throw new AcmeProtocolException("Could not compute hash", ex); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java index 071f3e3b..56207872 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java @@ -21,6 +21,7 @@ import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.ProofOfPossession01Challenge; import org.shredzone.acme4j.challenge.TlsSni01Challenge; +import org.shredzone.acme4j.challenge.TlsSni02Challenge; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.HttpConnector; import org.shredzone.acme4j.impl.DefaultConnection; @@ -35,6 +36,7 @@ import org.shredzone.acme4j.impl.GenericAcmeClient; * * @author Richard "Shred" Körber */ +@SuppressWarnings("deprecation") // must also provide deprecated challenges public abstract class AbstractAcmeClientProvider implements AcmeClientProvider { /** @@ -70,6 +72,7 @@ public abstract class AbstractAcmeClientProvider implements AcmeClientProvider { switch (type) { case Dns01Challenge.TYPE: return new Dns01Challenge(); case TlsSni01Challenge.TYPE: return new TlsSni01Challenge(); + case TlsSni02Challenge.TYPE: return new TlsSni02Challenge(); case ProofOfPossession01Challenge.TYPE: return new ProofOfPossession01Challenge(); case Http01Challenge.TYPE: return new Http01Challenge(); default: return null; diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java index 1e7e1cba..1567412d 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java @@ -33,7 +33,7 @@ import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.ProofOfPossession01Challenge; -import org.shredzone.acme4j.challenge.TlsSni01Challenge; +import org.shredzone.acme4j.challenge.TlsSni02Challenge; /** * Unit tests for {@link Authorization}. @@ -51,7 +51,7 @@ public class AuthorizationTest { public void setup() { Challenge challenge1 = setupChallenge(Http01Challenge.TYPE, new Http01Challenge()); Challenge challenge2 = setupChallenge(Dns01Challenge.TYPE, new Dns01Challenge()); - Challenge challenge3 = setupChallenge(TlsSni01Challenge.TYPE, new TlsSni01Challenge()); + Challenge challenge3 = setupChallenge(TlsSni02Challenge.TYPE, new TlsSni02Challenge()); List challenges = new ArrayList<>(); challenges.add(challenge1); @@ -111,7 +111,7 @@ public class AuthorizationTest { assertThat(c2, is(instanceOf(Http01Challenge.class))); // TlsSniChallenge is available, but not as standalone challenge - Challenge c3 = authorization.findChallenge(TlsSni01Challenge.TYPE); + Challenge c3 = authorization.findChallenge(TlsSni02Challenge.TYPE); assertThat(c3, is(nullValue())); } @@ -128,25 +128,25 @@ public class AuthorizationTest { assertThat(c1, contains(instanceOf(Http01Challenge.class))); // Available combined challenge - Collection c2 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni01Challenge.TYPE); + Collection c2 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni02Challenge.TYPE); assertThat(c2, hasSize(2)); assertThat(c2, contains(instanceOf(Dns01Challenge.class), - instanceOf(TlsSni01Challenge.class))); + instanceOf(TlsSni02Challenge.class))); // Order does not matter - Collection c3 = authorization.findCombination(TlsSni01Challenge.TYPE, Dns01Challenge.TYPE); + Collection c3 = authorization.findCombination(TlsSni02Challenge.TYPE, Dns01Challenge.TYPE); assertThat(c3, hasSize(2)); assertThat(c3, contains(instanceOf(Dns01Challenge.class), - instanceOf(TlsSni01Challenge.class))); + instanceOf(TlsSni02Challenge.class))); // Finds smaller combinations as well - Collection c4 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni01Challenge.TYPE, ProofOfPossession01Challenge.TYPE); + Collection c4 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni02Challenge.TYPE, ProofOfPossession01Challenge.TYPE); assertThat(c4, hasSize(2)); assertThat(c4, contains(instanceOf(Dns01Challenge.class), - instanceOf(TlsSni01Challenge.class))); + instanceOf(TlsSni02Challenge.class))); // Finds the smallest possible combination - Collection c5 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni01Challenge.TYPE, Http01Challenge.TYPE); + Collection c5 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni02Challenge.TYPE, Http01Challenge.TYPE); assertThat(c5, hasSize(1)); assertThat(c5, contains(instanceOf(Http01Challenge.class))); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni01ChallengeTest.java similarity index 95% rename from acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java rename to acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni01ChallengeTest.java index 7984a9d0..59dea871 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni01ChallengeTest.java @@ -31,7 +31,8 @@ import org.shredzone.acme4j.util.TestUtils; * * @author Richard "Shred" Körber */ -public class TlsSniChallengeTest { +@SuppressWarnings("deprecation") // must test a deprecated challenge +public class TlsSni01ChallengeTest { private static final String KEY_AUTHORIZATION = "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"; diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java new file mode 100644 index 00000000..b3e78301 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSni02ChallengeTest.java @@ -0,0 +1,79 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2016 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.*; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; + +import java.io.IOException; +import java.security.KeyPair; + +import org.junit.Test; +import org.shredzone.acme4j.Registration; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.util.ClaimBuilder; +import org.shredzone.acme4j.util.TestUtils; + +/** + * Unit tests for {@link TlsSni02Challenge}. + * + * @author Richard "Shred" Körber + */ +public class TlsSni02ChallengeTest { + + private static final String KEY_AUTHORIZATION = + "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"; + + /** + * Test that {@link TlsSni02Challenge} generates a correct authorization key. + */ + @Test + public void testTlsSni02Challenge() throws IOException { + KeyPair keypair = TestUtils.createKeyPair(); + Registration reg = new Registration(keypair); + + TlsSni02Challenge challenge = new TlsSni02Challenge(); + challenge.unmarshall(TestUtils.getJsonAsMap("tlsSni02Challenge")); + + assertThat(challenge.getType(), is(TlsSni02Challenge.TYPE)); + assertThat(challenge.getStatus(), is(Status.PENDING)); + + try { + challenge.getSubject(); + fail("getSubject() without previous authorize()"); + } catch (IllegalStateException ex) { + // expected + } + + try { + challenge.getSanB(); + fail("getSanB() without previous authorize()"); + } catch (IllegalStateException ex) { + // expected + } + + challenge.authorize(reg); + + assertThat(challenge.getSubject(), is("5bf0b9908ed73bc53ed3327afa52f76b.0a4bea00520f0753f42abe0bb39e3ea8.token.acme.invalid")); + assertThat(challenge.getSanB(), is("14e2350a04434f93c2e0b6012968d99d.ed459b6a7a019d9695609b8514f9d63d.ka.acme.invalid")); + + ClaimBuilder cb = new ClaimBuilder(); + challenge.respond(cb); + + assertThat(cb.toString(), sameJSONAs("{\"keyAuthorization\"=\"" + + KEY_AUTHORIZATION + "\"}").allowingExtraUnexpectedFields()); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeClientProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeClientProviderTest.java index cb9528a6..b9ac9c61 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeClientProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeClientProviderTest.java @@ -25,7 +25,7 @@ import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.ProofOfPossession01Challenge; -import org.shredzone.acme4j.challenge.TlsSni01Challenge; +import org.shredzone.acme4j.challenge.TlsSni02Challenge; /** * Unit tests for {@link AbstractAcmeClientProvider}. @@ -76,6 +76,7 @@ public class AbstractAcmeClientProviderTest { * Test that challenges are generated properly. */ @Test + @SuppressWarnings("deprecation") // must test deprecated challenges public void testCreateChallenge() { AbstractAcmeClientProvider provider = new AbstractAcmeClientProvider() { @Override @@ -104,12 +105,16 @@ public class AbstractAcmeClientProviderTest { assertThat(c4, not(nullValue())); assertThat(c4, instanceOf(ProofOfPossession01Challenge.class)); - Challenge c5 = provider.createChallenge(TlsSni01Challenge.TYPE); + Challenge c5 = provider.createChallenge(org.shredzone.acme4j.challenge.TlsSni01Challenge.TYPE); assertThat(c5, not(nullValue())); - assertThat(c5, instanceOf(TlsSni01Challenge.class)); + assertThat(c5, instanceOf(org.shredzone.acme4j.challenge.TlsSni01Challenge.class)); - Challenge c6 = provider.createChallenge("foobar-01"); - assertThat(c6, is(nullValue())); + Challenge c6 = provider.createChallenge(TlsSni02Challenge.TYPE); + assertThat(c6, not(nullValue())); + assertThat(c6, instanceOf(TlsSni02Challenge.class)); + + Challenge c7 = provider.createChallenge("foobar-01"); + assertThat(c7, is(nullValue())); try { provider.createChallenge(null); diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index 5b11c508..92cdf038 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -163,4 +163,11 @@ tlsSniChallenge = \ "token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ" \ } +tlsSni02Challenge = \ + { \ + "type":"tls-sni-02", \ + "status":"pending", \ + "token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ" \ + } + # \ No newline at end of file diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java index 07f0c2ee..b1dc70a9 100644 --- a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java @@ -31,7 +31,6 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; -import org.shredzone.acme4j.challenge.TlsSni01Challenge; import org.shredzone.acme4j.exception.AcmeConflictException; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeUnauthorizedException; @@ -267,11 +266,12 @@ public class ClientTest { /** * Prepares TLS-SNI challenge. */ + @SuppressWarnings("deprecation") // until tls-sni-02 is supported public Challenge tlsSniChallenge(Authorization auth, Registration reg, String domain) throws AcmeException { // Find a single tls-sni-01 challenge - TlsSni01Challenge challenge = auth.findChallenge(TlsSni01Challenge.TYPE); + org.shredzone.acme4j.challenge.TlsSni01Challenge challenge = auth.findChallenge(org.shredzone.acme4j.challenge.TlsSni01Challenge.TYPE); if (challenge == null) { - LOG.error("Found no " + TlsSni01Challenge.TYPE + " challenge, don't know what to do..."); + LOG.error("Found no " + org.shredzone.acme4j.challenge.TlsSni01Challenge.TYPE + " challenge, don't know what to do..."); return null; } diff --git a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java index 8f6d194c..b50e9214 100644 --- a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java +++ b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java @@ -37,7 +37,7 @@ import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.pkcs.PKCS10CertificationRequest; -import org.shredzone.acme4j.challenge.TlsSni01Challenge; +import org.shredzone.acme4j.challenge.TlsSni02Challenge; /** * Utility class offering convenience methods for certificates. @@ -113,14 +113,18 @@ public final class CertificateUtils { /** * Creates a self-signed {@link X509Certificate} that can be used for - * {@link TlsSni01Challenge}. The certificate is valid for 7 days. + * {@link org.shredzone.acme4j.challenge.TlsSni01Challenge}. The certificate is valid + * for 7 days. * * @param keypair * A domain {@link KeyPair} to be used for the challenge * @param subject * Subject to create a certificate for * @return Created certificate + * @deprecated Will be removed when + * {@link org.shredzone.acme4j.challenge.TlsSni01Challenge} is removed */ + @Deprecated public static X509Certificate createTlsSniCertificate(KeyPair keypair, String subject) throws IOException { final long now = System.currentTimeMillis(); final long validSpanMs = 7 * 24 * 60 * 60 * 1000L; @@ -151,4 +155,48 @@ public final class CertificateUtils { } } + /** + * Creates a self-signed {@link X509Certificate} that can be used for + * {@link TlsSni02Challenge}. The certificate is valid for 7 days. + * + * @param keypair + * A domain {@link KeyPair} to be used for the challenge + * @param sanA + * SAN-A to be used in the certificate + * @param sanB + * SAN-B to be used in the certificate + * @return Created certificate + */ + public static X509Certificate createTlsSni02Certificate(KeyPair keypair, String sanA, String sanB) + throws IOException { + final long now = System.currentTimeMillis(); + final long validSpanMs = 7 * 24 * 60 * 60 * 1000L; + final String signatureAlg = "SHA256withRSA"; + + try { + X500Name issuer = new X500Name("CN=acme.invalid"); + BigInteger serial = BigInteger.valueOf(now); + Date notBefore = new Date(now); + Date notAfter = new Date(now + validSpanMs); + + JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, issuer, keypair.getPublic()); + + GeneralName[] gns = new GeneralName[2]; + gns[0] = new GeneralName(GeneralName.dNSName, sanA); + gns[1] = new GeneralName(GeneralName.dNSName, sanB); + + certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns)); + + 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); + } + } + } diff --git a/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java b/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java index d2acfb63..a0b84128 100644 --- a/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java +++ b/acme4j-utils/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java @@ -90,6 +90,7 @@ public class CertificateUtilsTest { * good certificate. */ @Test + @SuppressWarnings("deprecation") // test deprecated method public void testCreateTlsSniCertificate() throws IOException, CertificateParsingException { String subject = "30c452b9bd088cdbc2c4094947025d7c.7364ea602ac325a1b55ceaae024fbe29.acme.invalid"; @@ -108,6 +109,30 @@ public class CertificateUtilsTest { assertThat(getSANs(cert), containsInAnyOrder(subject)); } + /** + * Test if {@link CertificateUtils#createTlsSni02Certificate(KeyPair, String)} creates + * a good certificate. + */ + @Test + public void testCreateTlsSni02Certificate() throws IOException, CertificateParsingException { + String sanA = "1082909237a535173c8415a44539f84e.248317530d8d1a0c71de8fd23f1beae4.token.acme.invalid"; + String sanB = "edc3a1d40199c1723358d57853bc23ff.4d4473417a6d76e80df17bbcfbe53d2c.ka.acme.invalid"; + + KeyPair keypair = KeyPairUtils.createKeyPair(2048); + + X509Certificate cert = CertificateUtils.createTlsSni02Certificate(keypair, sanA, sanB); + + Date now = new Date(); + Date end = new Date(now.getTime() + (8 * 24 * 60 * 60 * 1000L)); + + assertThat(cert, not(nullValue())); + assertThat(cert.getNotAfter(), is(greaterThan(now))); + assertThat(cert.getNotAfter(), is(lessThan(end))); + assertThat(cert.getNotBefore(), is(lessThanOrEqualTo(now))); + assertThat(cert.getSubjectX500Principal().getName(), is("CN=acme.invalid")); + assertThat(getSANs(cert), containsInAnyOrder(sanA, sanB)); + } + /** * Test if {@link CertificateUtils#readCSR(InputStream)} reads an identical CSR. */ diff --git a/src/site/markdown/challenge/index.md b/src/site/markdown/challenge/index.md index ac58669a..3ee0cdf3 100644 --- a/src/site/markdown/challenge/index.md +++ b/src/site/markdown/challenge/index.md @@ -6,9 +6,10 @@ There are different kind of challenges. The most simple is maybe the HTTP challe The CA offers one or more sets of challenges. At least one set has to be completed in order to prove ownership. -The ACME specifications define four standard challenges: +The ACME specifications define these standard challenges: * [http-01](./http-01.html) * [dns-01](./dns-01.html) * [tls-sni-01](./tls-sni-01.html) +* [tls-sni-02](./tls-sni-02.html) * [proof-of-possession-01](./proof-of-possession-01.html) diff --git a/src/site/markdown/challenge/tls-sni-01.md b/src/site/markdown/challenge/tls-sni-01.md index cfe4fef8..67a69976 100644 --- a/src/site/markdown/challenge/tls-sni-01.md +++ b/src/site/markdown/challenge/tls-sni-01.md @@ -1,5 +1,7 @@ # tls-sni-01 Challenge +> **DEPRECATED:** According to the ACME specifications, this challenge will be replaced by [tls-sni-02](./tls-sni-02.html). However, _Let's Encrypt_ does not currently support `tls-sni-02`. For the time being, _acme4j_ supports both challenges. To be on the safe side, request both challenges and process the one that is returned. + With the `tls-sni-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 SNI request with a specific self-signed cert. After authorizing the challenge, `TlsSni01Challenge` provides a subject: diff --git a/src/site/markdown/challenge/tls-sni-02.md b/src/site/markdown/challenge/tls-sni-02.md new file mode 100644 index 00000000..bcf85870 --- /dev/null +++ b/src/site/markdown/challenge/tls-sni-02.md @@ -0,0 +1,45 @@ +# tls-sni-02 Challenge + +> **NOTE:** According to the ACME specifications, this challenge will replace [tls-sni-01](./tls-sni-01.html). However, _Let's Encrypt_ does not currently support `tls-sni-02`. For the time being, _acme4j_ supports both challenges. To be on the safe side, request both challenges and process the one that is returned. + +With the `tls-sni-02` 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 SNI request with a specific self-signed cert. + +After authorizing the challenge, `TlsSni02Challenge` provides a subject and a key-authorization domain: + +```java +TlsSni02Challenge challenge = auth.findChallenge(TlsSni02Challenge.TYPE); +challenge.authorize(registration); + +String subject = challenge.getSubject(); // SAN-A +String sanB = challenge.getSanB(); // SAN-B +``` + +`subject` and `sanB` are basically domain names formed like in this example: + +``` +5bf0b9908ed73bc53ed3327afa52f76b.0a4bea00520f0753f42abe0bb39e3ea8.token.acme.invalid +14e2350a04434f93c2e0b6012968d99d.ed459b6a7a019d9695609b8514f9d63d.ka.acme.invalid +``` + +You need to create a self-signed certificate with both `subject` and `sanB` set as _Subject Alternative Name_. After that, configure your web server so it will use this certificate on a SNI request to `subject`. + +The `TlsSni02Challenge` 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 +KeyPair sniKeyPair = KeyPairUtils.createKeyPair(2048); +X509Certificate cert = CertificateUtils.createTlsSni02Certificate(sniKeyPair, subject, sanB); +``` + +Now use `cert` and `sniKeyPair` to let your web server respond to SNI requests to `subject`. The CA is not allowed to reveal `sanB`, so it will not perform SNI requests to that domain. + +The challenge is completed when the CA was able to send the SNI request and get the correct certificate in return. + +This shell command line may be helpful to test your web server configuration: + +```shell +echo QUIT | \ + openssl s_client -servername $subject -connect $server_ip:443 | \ + openssl x509 -text -noout +``` + +It should return a certificate with both `subject` and `sanB` set as `X509v3 Subject Alternative Name`. diff --git a/src/site/site.xml b/src/site/site.xml index f7b1d553..f0c9c207 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -40,6 +40,7 @@ +