From 0e7da2a1d0886fdc90f6c317f418a905e4443cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Tue, 22 Dec 2015 23:34:16 +0100 Subject: [PATCH] Add TLS-SNI challenge --- README.md | 4 +- .../acme4j/challenge/TlsSniChallenge.java | 82 +++++++++++++++++-- .../acme4j/challenge/TlsSniChallengeTest.java | 82 +++++++++++++++++++ .../src/test/resources/json.properties | 7 ++ .../acme4j/util/CertificateUtils.java | 53 ++++++++++++ .../acme4j/util/CertificateUtilsTest.java | 45 ++++++++++ src/site/markdown/challenge/dns.md | 2 + src/site/markdown/challenge/http.md | 2 + src/site/markdown/challenge/tls-sni.md | 27 +++++- src/site/site.xml | 2 +- 10 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java diff --git a/README.md b/README.md index 39a63883..924cff01 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ See the [online documentation](http://www.shredzone.org/maven/acme4j/) for how t ## Missing -The following features are planned to be completed for the first beta release, but are still missing: +The following feature is planned to be completed for the first beta release, but is still missing: -* `proofOfPossession-01` and `tls-sni-01` challenge support. +* `proofOfPossession-01` challenge support. ## Contribute diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java index a550d664..d4421508 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java @@ -13,10 +13,16 @@ */ package org.shredzone.acme4j.challenge; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.jose4j.base64url.Base64Url; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.util.ClaimBuilder; + /** * Implements the {@code tls-sni-01} challenge. - *

- * TODO: Currently this challenge is not implemented. * * @author Richard "Shred" Körber */ @@ -28,20 +34,59 @@ public class TlsSniChallenge extends GenericChallenge { */ public static final String TYPE = "tls-sni-01"; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private String authorization = null; + private String subject = null; + + /** + * Returns the token to be used for this challenge. + */ public String getToken() { return get(KEY_TOKEN); } + /** + * Sets the token to be used. + */ public void setToken(String token) { put(KEY_TOKEN, token); } - public int getN() { - return get("n"); + /** + * Returns the authorization string. + */ + public String getAuthorization() { + if (authorization == null) { + throw new IllegalStateException("Challenge is not authorized yet"); + } + return authorization; } - public void setN(int n) { - put("n", n); + /** + * Return the subject to generate a self-signed certificate for. + */ + public String getSubject() { + if (authorization == null) { + throw new IllegalStateException("Challenge is not authorized yet"); + } + return subject; + } + + @Override + public void authorize(Account account) { + super.authorize(account); + authorization = getToken() + '.' + Base64Url.encode(jwkThumbprint(account.getKeyPair().getPublic())); + + String hash = computeHash(authorization); + subject = hash.substring(0, 32) + '.' + hash.substring(32) + ".acme.invalid"; + } + + @Override + public void marshall(ClaimBuilder cb) { + cb.put(KEY_KEY_AUTHORIZSATION, getAuthorization()); + cb.put(KEY_TYPE, getType()); + cb.put(KEY_TOKEN, getToken()); } @Override @@ -49,4 +94,29 @@ public class TlsSniChallenge extends GenericChallenge { 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) { + // Algorithm and Encoding are standard on Java + throw new RuntimeException(ex); + } + } + } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java new file mode 100644 index 00000000..5e6568f6 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsSniChallengeTest.java @@ -0,0 +1,82 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 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.Account; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.util.ClaimBuilder; +import org.shredzone.acme4j.util.TestUtils; + +/** + * Unit tests for {@link TlsSniChallenge}. + * + * @author Richard "Shred" Körber + */ +public class TlsSniChallengeTest { + + private static final String TOKEN = + "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ"; + private static final String KEY_AUTHORIZATION = + "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"; + + /** + * Test that {@link TlsSniChallenge} generates a correct authorization key. + */ + @Test + public void testTlsSniChallenge() throws IOException { + KeyPair keypair = TestUtils.createKeyPair(); + Account account = new Account(keypair); + + TlsSniChallenge challenge = new TlsSniChallenge(); + challenge.unmarshall(TestUtils.getJsonAsMap("tlsSniChallenge")); + + assertThat(challenge.getType(), is(TlsSniChallenge.TYPE)); + assertThat(challenge.getStatus(), is(Status.PENDING)); + + try { + challenge.getAuthorization(); + fail("getAuthorization() without previous authorize()"); + } catch (IllegalStateException ex) { + // expected + } + + try { + challenge.getSubject(); + fail("getSubject() without previous authorize()"); + } catch (IllegalStateException ex) { + // expected + } + + challenge.authorize(account); + + assertThat(challenge.getToken(), is(TOKEN)); + assertThat(challenge.getAuthorization(), is(KEY_AUTHORIZATION)); + assertThat(challenge.getSubject(), is("14e2350a04434f93c2e0b6012968d99d.ed459b6a7a019d9695609b8514f9d63d.acme.invalid")); + + ClaimBuilder cb = new ClaimBuilder(); + challenge.marshall(cb); + + assertThat(cb.toString(), sameJSONAs("{\"keyAuthorization\"=\"" + + KEY_AUTHORIZATION + "\"}").allowingExtraUnexpectedFields()); + } + +} diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index 1d90ee17..b8350057 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -150,4 +150,11 @@ httpChallenge = \ "token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" \ } +tlsSniChallenge = \ + { \ + "type":"tls-sni-01", \ + "status":"pending", \ + "token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ" \ + } + # \ No newline at end of file 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 25ca9742..189c4868 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 @@ -13,16 +13,28 @@ */ package org.shredzone.acme4j.util; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; +import java.math.BigInteger; +import java.security.KeyPair; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.Date; +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.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.shredzone.acme4j.challenge.TlsSniChallenge; /** * Utility class offering convenience methods for certificates. @@ -79,4 +91,45 @@ public final class CertificateUtils { } } + /** + * Creates a self-signed {@link X509Certificate} that can be used for + * {@link TlsSniChallenge}. The certificate is valid for 7 days. + * + * @param subject + * Subject to create a certificate for + * @return Created certificate + */ + public static X509Certificate createTlsSniCertificate(String subject) throws IOException { + final int certSize = 2048; + final long now = System.currentTimeMillis(); + final long validSpanMs = 7 * 24 * 60 * 60 * 1000L; + final String signatureAlg = "SHA256withRSA"; + + try { + KeyPair keypair = KeyPairUtils.createKeyPair(certSize); + + 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[1]; + gns[0] = new GeneralName(GeneralName.dNSName, subject); + + 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 c9421e60..a95917df 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 @@ -22,8 +22,14 @@ import java.io.IOException; import java.io.InputStream; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; +import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.bouncycastle.asn1.x509.GeneralName; import org.junit.Before; import org.junit.Test; @@ -77,4 +83,43 @@ public class CertificateUtilsTest { assertThat(original.getEncoded(), is(equalTo(written.getEncoded()))); } + /** + * Test if {@link CertificateUtils#createTlsSniCertificate(String)} creates a + * good certificate. + */ + @Test + public void testCreateTlsSniCertificate() throws IOException, CertificateParsingException { + String subject = "30c452b9bd088cdbc2c4094947025d7c.7364ea602ac325a1b55ceaae024fbe29.acme.invalid"; + Date now = new Date(); + Date end = new Date(now.getTime() + (8 * 24 * 60 * 60 * 1000L)); + + X509Certificate cert = CertificateUtils.createTlsSniCertificate(subject); + + 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(subject)); + } + + /** + * Extracts all DNSName SANs from a certificate. + * + * @param cert + * {@link X509Certificate} + * @return Set of DNSName + */ + private Set getSANs(X509Certificate cert) throws CertificateParsingException { + Set result = new HashSet<>(); + + for (List list : cert.getSubjectAlternativeNames()) { + if (((Number) list.get(0)).intValue() == GeneralName.dNSName) { + result.add((String) list.get(1)); + } + } + + return result; + } + } diff --git a/src/site/markdown/challenge/dns.md b/src/site/markdown/challenge/dns.md index addcbe8c..1dffa84b 100644 --- a/src/site/markdown/challenge/dns.md +++ b/src/site/markdown/challenge/dns.md @@ -1,5 +1,7 @@ # DNS Challenge +With the DNS challenge, you prove to the CA that you are able to control the DNS records of the domain to be authorized, by creating a TXT record with a signed content. + After authorizing the challenge, `DnsChallenge` provides a digest string: ```java diff --git a/src/site/markdown/challenge/http.md b/src/site/markdown/challenge/http.md index 11042ead..9c5e007d 100644 --- a/src/site/markdown/challenge/http.md +++ b/src/site/markdown/challenge/http.md @@ -1,5 +1,7 @@ # HTTP Challenge +With the HTTP challenge, you prove to the CA that you are able to control the web site content of the domain to be authorized, by making a file with a signed content available at a given path. + After authorizing the challenge, `HttpChallenge` provides two strings: ```java diff --git a/src/site/markdown/challenge/tls-sni.md b/src/site/markdown/challenge/tls-sni.md index a2f0c8c3..1980edd7 100644 --- a/src/site/markdown/challenge/tls-sni.md +++ b/src/site/markdown/challenge/tls-sni.md @@ -1,3 +1,28 @@ # TLS-SNI -This challenge is not yet implemented in _acme4j_. +With the TLS-SNI 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 self-signed cert. + +After authorizing the challenge, `TlsSniChallenge` provides a subject: + +```java +TlsSniChallenge challenge = auth.findChallenge(TlsSniChallenge.TYPE); +challenge.authorize(account); + +String subject = challenge.getSubject(); +``` + +The `subject` is basically a domain name formed like in this example: + +``` +30c452b9bd088cdbc2c4094947025d7c.7364ea602ac325a1b55ceaae024fbe29.acme.invalid +``` + +You need to create a self-signed certificate with the subject set as _Subject Alternative Name_. After that, configure your web server so it will use this certificate on a SNI request to the `subject`. + +The `TlsSniChallenge` 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 +X509Certificate cert = CertificateUtils.createTlsSniCertificate(String subject); +``` + +The challenge is completed when the CA was able to send the SNI request and get the correct certificate in return. diff --git a/src/site/site.xml b/src/site/site.xml index b11b76f4..f0e134ae 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -39,7 +39,7 @@ - +