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