From e22b47f140a43bf61f4864f5adacaee4caa3841f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Fri, 19 May 2023 10:20:07 +0200 Subject: [PATCH] Create tls-alpn-01 cert in challenge class --- .../acme4j/challenge/TlsAlpn01Challenge.java | 25 ++++++++++++++++++ .../challenge/TlsAlpn01ChallengeTest.java | 26 +++++++++++++++++++ src/doc/docs/challenge/tls-alpn-01.md | 20 +++++--------- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java index b03d8cfe..1b88f920 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java @@ -15,8 +15,14 @@ package org.shredzone.acme4j.challenge; import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash; +import java.io.IOException; +import java.security.KeyPair; +import java.security.cert.X509Certificate; + +import org.shredzone.acme4j.Identifier; import org.shredzone.acme4j.Login; import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.util.CertificateUtils; /** * Implements the {@value TYPE} challenge. It requires a specific certificate that can be @@ -63,6 +69,25 @@ public class TlsAlpn01Challenge extends TokenChallenge { return sha256hash(getAuthorization()); } + /** + * Creates a self-signed {@link X509Certificate} for this challenge. The certificate + * is valid for 7 days. + * + * @param keypair + * A domain {@link KeyPair} to be used for the challenge + * @param id + * The {@link Identifier} that is to be validated + * @return Created certificate + * @since 3.0.0 + */ + public X509Certificate createCertificate(KeyPair keypair, Identifier id) { + try { + return CertificateUtils.createTlsAlpn01Certificate(keypair, id, getAcmeValidation()); + } catch (IOException ex) { + throw new IllegalArgumentException("Bad certificate parameters", ex); + } + } + @Override protected boolean acceptable(String type) { return TYPE.equals(type); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java index 585c057b..47e2ec36 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java @@ -15,14 +15,19 @@ package org.shredzone.acme4j.challenge; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; +import java.security.cert.CertificateParsingException; + import org.junit.jupiter.api.Test; +import org.shredzone.acme4j.Identifier; import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSONBuilder; import org.shredzone.acme4j.toolbox.TestUtils; +import org.shredzone.acme4j.util.KeyPairUtils; /** * Unit tests for {@link TlsAlpn01ChallengeTest}. @@ -54,4 +59,25 @@ public class TlsAlpn01ChallengeTest { assertThatJson(response.toString()).isEqualTo("{}"); } + /** + * Test that {@link TlsAlpn01Challenge} generates a correct test certificate + */ + @Test + public void testTlsAlpn01Certificate() throws CertificateParsingException { + var challenge = new TlsAlpn01Challenge(login, getJSON("tlsAlpnChallenge")); + var keypair = KeyPairUtils.createKeyPair(2048); + var subject = Identifier.dns("example.com"); + + var certificate = challenge.createCertificate(keypair, subject); + + // Only check the main requirements. Cert generation is fully tested in CertificateUtilsTest. + assertThat(certificate).isNotNull(); + assertThat(certificate.getSubjectX500Principal().getName()).isEqualTo("CN=acme.invalid"); + assertThat(certificate.getSubjectAlternativeNames().stream() + .map(l -> l.get(1)) + .map(Object::toString)).contains(subject.getDomain()); + assertThat(certificate.getCriticalExtensionOIDs()).contains(TlsAlpn01Challenge.ACME_VALIDATION_OID); + assertThatNoException().isThrownBy(() -> certificate.verify(keypair.getPublic())); + } + } diff --git a/src/doc/docs/challenge/tls-alpn-01.md b/src/doc/docs/challenge/tls-alpn-01.md index bed1bb4b..1fc27bff 100644 --- a/src/doc/docs/challenge/tls-alpn-01.md +++ b/src/doc/docs/challenge/tls-alpn-01.md @@ -2,30 +2,22 @@ With the `tls-alpn-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 request with a specific self-signed cert utilizing the ALPN extension. This challenge is specified in [RFC 8737](https://tools.ietf.org/html/rfc8737). -`TlsAlpn01Challenge` provides a byte array called `acmeValidation`: +You need to create a self-signed certificate with the domain to be validated set as the only _Subject Alternative Name_. The byte array provided by `challenge.getAcmeValidation()` must be set as DER encoded `OCTET STRING` extension with the object id `1.3.6.1.5.5.7.1.31`. It is required to set this extension as critical. + +_acme4j_ does the heavy lifting for you though, and provides a certificate that is ready to use. It is valid for 7 days, which is ample of time to perform the validation. ```java TlsAlpn01Challenge challenge = auth.findChallenge(TlsAlpn01Challenge.class); Identifier identifier = auth.getIdentifier(); - -byte[] acmeValidation = challenge.getAcmeValidation(); -``` - -You need to create a self-signed certificate with the domain to be validated set as the only _Subject Alternative Name_. The `acmeValidation` must be set as DER encoded `OCTET STRING` extension with the object id `1.3.6.1.5.5.7.1.31`. It is required to set this extension as critical. - -After that, configure your web server so it will use this certificate on an incoming TLS request having the SNI `identifier` and the ALPN protocol `acme-tls/1`. - -The `TlsAlpn01Challenge` class does not generate a self-signed certificate for you, as it would require _Bouncy Castle_. However, there is a utility method for this use case: - -```java KeyPair certKeyPair = KeyPairUtils.createKeyPair(2048); -X509Certificate cert = CertificateUtils. - createTlsAlpn01Certificate(certKeyPair, identifier, acmeValidation); +X509Certificate cert = challenge.createCertificate(certKeyPair, identifier); ``` Now use `cert` and `certKeyPair` to let your web server respond to TLS requests containing an ALPN extension with the value `acme-tls/1` and a SNI extension containing your subject (`identifier`). +When the validation is completed, the `cert` and `certKeyPair` are not used anymore and can be disposed. + !!! note The request is sent to port 443 only. If your domain has multiple IP addresses, the CA randomly selects some of them. There is no way to choose a different port or a fixed IP address.