mirror of https://github.com/shred/acme4j
Add TLS-SNI challenge
parent
37dcb1f64b
commit
0e7da2a1d0
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -150,4 +150,11 @@ httpChallenge = \
|
|||
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" \
|
||||
}
|
||||
|
||||
tlsSniChallenge = \
|
||||
{ \
|
||||
"type":"tls-sni-01", \
|
||||
"status":"pending", \
|
||||
"token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ" \
|
||||
}
|
||||
|
||||
#
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in New Issue