Add TLS-SNI challenge

pull/17/merge
Richard Körber 2015-12-22 23:34:16 +01:00
parent 37dcb1f64b
commit 0e7da2a1d0
10 changed files with 296 additions and 10 deletions

View File

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

View File

@ -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.
* <p>
* <em>TODO: Currently this challenge is not implemented.</em>
*
* @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);
}
}
}

View File

@ -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());
}
}

View File

@ -150,4 +150,11 @@ httpChallenge = \
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" \
}
tlsSniChallenge = \
{ \
"type":"tls-sni-01", \
"status":"pending", \
"token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ" \
}
#

View File

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

View File

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

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@
<item name="Challenges" href="challenge/index.html">
<item name="HTTP" href="challenge/http.html"/>
<item name="DNS" href="challenge/dns.html"/>
<item name="tls-sni" href="challenge/tls-sni.html"/>
<item name="TLS-SNI" href="challenge/tls-sni.html"/>
<item name="Proof of Possession" href="challenge/proof-of-possession.html"/>
</item>
<item name="CAs" href="ca/index.html">