diff --git a/README.md b/README.md index c07230cf..5fea222e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This Java client helps to connect to an ACME server, and performing all necessar * Supports [RFC 9444](https://tools.ietf.org/html/rfc9444) for subdomain validation * Supports [draft-ietf-acme-ari-07](https://www.ietf.org/archive/id/draft-ietf-acme-ari-07.html) for renewal information (experimental) * Supports [draft-aaron-acme-profiles-00](https://www.ietf.org/archive/id/draft-aaron-acme-profiles-00.html) for certificate profiles (experimental) +* Supports [draft-ietf-acme-dns-account-label-00](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-label/) for DNS labeled with ACME account ID challenges (experimental) * Easy to use Java API * Requires JRE 17 or higher * Supports [Buypass](https://buypass.com/), [Google Trust Services](https://pki.goog/), [Let's Encrypt](https://letsencrypt.org/), [SSL.com](https://www.ssl.com/), [ZeroSSL](https://zerossl.com/), and all other CAs that comply with the ACME protocol (RFC 8555). Note that _acme4j_ is an independent project that is not supported or endorsed by any of the CAs. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Dns01Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Dns01Challenge.java index e76faf08..1f9d897d 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Dns01Challenge.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Dns01Challenge.java @@ -49,7 +49,9 @@ public class Dns01Challenge extends TokenChallenge { * @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note * the trailing full stop character). * @since 2.14 + * @deprecated Use {@link #getRRName(Identifier)} */ + @Deprecated public static String toRRName(Identifier identifier) { return toRRName(identifier.getDomain()); } @@ -63,7 +65,9 @@ public class Dns01Challenge extends TokenChallenge { * @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note * the trailing full stop character). * @since 2.14 + * @deprecated Use {@link #getRRName(String)} */ + @Deprecated public static String toRRName(String domain) { return RECORD_NAME_PREFIX + '.' + domain + '.'; } @@ -80,6 +84,34 @@ public class Dns01Challenge extends TokenChallenge { super(login, data); } + /** + * Converts a domain identifier to the Resource Record name to be used for the DNS TXT + * record. + * + * @param identifier + * Domain {@link Identifier} of the domain to be validated + * @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note + * the trailing full stop character). + * @since 3.6.0 + */ + public String getRRName(Identifier identifier) { + return toRRName(identifier); + } + + /** + * Converts a domain identifier to the Resource Record name to be used for the DNS TXT + * record. + * + * @param domain + * Domain name to be validated + * @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note + * the trailing full stop character). + * @since 3.6.0 + */ + public String getRRName(String domain) { + return toRRName(domain); + } + /** * Returns the digest string to be set in the domain's {@code _acme-challenge} TXT * record. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsAccount01Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsAccount01Challenge.java new file mode 100644 index 00000000..3aae80bd --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsAccount01Challenge.java @@ -0,0 +1,106 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2025 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.shredzone.acme4j.toolbox.AcmeUtils.*; + +import java.io.Serial; +import java.net.URL; +import java.util.Arrays; +import java.util.Locale; + +import org.shredzone.acme4j.Identifier; +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.toolbox.JSON; + +/** + * Implements the {@value TYPE} challenge. It requires a specific DNS record for domain + * validation. See the acme4j documentation for a detailed explanation. + * + * @draft This class is currently based on an RFC draft. It may be changed or removed + * without notice to reflect future changes to the draft. SemVer rules do not apply here. + * @since 3.6.0 + */ +public class DnsAccount01Challenge extends TokenChallenge { + @Serial + private static final long serialVersionUID = -1098129409378900733L; + + /** + * Challenge type name: {@value} + */ + public static final String TYPE = "dns-account-01"; + + /** + * Creates a new generic {@link DnsAccount01Challenge} object. + * + * @param login + * {@link Login} the resource is bound with + * @param data + * {@link JSON} challenge data + */ + public DnsAccount01Challenge(Login login, JSON data) { + super(login, data); + } + + /** + * Converts a domain identifier to the Resource Record name to be used for the DNS TXT + * record. + * + * @param identifier + * {@link Identifier} to be validated + * @return Resource Record name (e.g. + * {@code _ujmmovf2vn55tgye._acme-challenge.example.org.}, note the trailing full stop + * character). + */ + public String getRRName(Identifier identifier) { + return getRRName(identifier.getDomain()); + } + + /** + * Converts a domain identifier to the Resource Record name to be used for the DNS TXT + * record. + * + * @param domain + * Domain name to be validated + * @return Resource Record name (e.g. + * {@code _ujmmovf2vn55tgye._acme-challenge.example.org.}, note the trailing full stop + * character). + */ + public String getRRName(String domain) { + return getPrefix(getLogin().getAccount().getLocation()) + '.' + domain + '.'; + } + + /** + * Returns the digest string to be set in the domain's TXT record. + */ + public String getDigest() { + return base64UrlEncode(sha256hash(getAuthorization())); + } + + /** + * Returns the prefix of an account location. + */ + private String getPrefix(URL accountLocation) { + var urlHash = sha256hash(accountLocation.toExternalForm()); + var hash = base32Encode(Arrays.copyOfRange(urlHash, 0, 10)); + return "_" + hash.toLowerCase(Locale.ENGLISH) + + "._acme-challenge"; + } + + @Override + protected boolean acceptable(String type) { + return TYPE.equals(type); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java index 3e153270..86bd03dd 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java @@ -25,6 +25,7 @@ import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; +import org.shredzone.acme4j.challenge.DnsAccount01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.challenge.TokenChallenge; @@ -83,6 +84,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider { var map = new HashMap(); map.put(Dns01Challenge.TYPE, Dns01Challenge::new); + map.put(DnsAccount01Challenge.TYPE, DnsAccount01Challenge::new); map.put(Http01Challenge.TYPE, Http01Challenge::new); map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java index fd26a94a..74e718aa 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java @@ -72,6 +72,8 @@ public final class AcmeUtils { private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + private static final char[] BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray(); + /** * Enumeration of PEM labels. */ @@ -154,6 +156,59 @@ public final class AcmeUtils { return URL_DECODER.decode(base64); } + /** + * Base32 encodes a byte array. + * + * @param data Byte array to encode + * @return Base32 encoded data (includes padding) + * @since 3.6.0 + */ + public static String base32Encode(byte[] data) { + var result = new StringBuilder(); + var unconverted = new int[5]; + var converted = new int[8]; + + for (var ix = 0; ix < (data.length + 4) / 5; ix++) { + var blocklen = unconverted.length; + for (var pos = 0; pos < unconverted.length; pos++) { + if ((ix * 5 + pos) < data.length) { + unconverted[pos] = data[ix * 5 + pos] & 0xFF; + } else { + unconverted[pos] = 0; + blocklen--; + } + } + + converted[0] = (unconverted[0] >> 3) & 0x1F; + converted[1] = ((unconverted[0] & 0x07) << 2) | ((unconverted[1] >> 6) & 0x03); + converted[2] = (unconverted[1] >> 1) & 0x1F; + converted[3] = ((unconverted[1] & 0x01) << 4) | ((unconverted[2] >> 4) & 0x0F); + converted[4] = ((unconverted[2] & 0x0F) << 1) | ((unconverted[3] >> 7) & 0x01); + converted[5] = (unconverted[3] >> 2) & 0x1F; + converted[6] = ((unconverted[3] & 0x03) << 3) | ((unconverted[4] >> 5) & 0x07); + converted[7] = unconverted[4] & 0x1F; + + var padding = switch (blocklen) { + case 1 -> 6; + case 2 -> 4; + case 3 -> 3; + case 4 -> 1; + case 5 -> 0; + default -> throw new IllegalArgumentException("blocklen " + blocklen + " out of range"); + }; + + Arrays.stream(converted) + .limit(converted.length - padding) + .map(v -> BASE32_ALPHABET[v]) + .forEach(v -> result.append((char) v)); + + if (padding > 0) { + result.append("=".repeat(padding)); + } + } + return result.toString(); + } + /** * Validates that the given {@link String} is a valid base64url encoded value. * diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java index 512efd48..15a05bf4 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java @@ -88,7 +88,7 @@ public class ChallengeTest { @Test public void testNotAcceptable() { assertThrows(AcmeProtocolException.class, () -> - new Http01Challenge(TestUtils.login(), getJSON("dnsChallenge")) + new Http01Challenge(TestUtils.login(), getJSON("dns01Challenge")) ); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/Dns01ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/Dns01ChallengeTest.java index 8a7fced0..ae78cc37 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/Dns01ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/Dns01ChallengeTest.java @@ -38,29 +38,22 @@ public class Dns01ChallengeTest { */ @Test public void testDnsChallenge() { - var challenge = new Dns01Challenge(login, getJSON("dnsChallenge")); + var challenge = new Dns01Challenge(login, getJSON("dns01Challenge")); assertThat(challenge.getType()).isEqualTo(Dns01Challenge.TYPE); assertThat(challenge.getStatus()).isEqualTo(Status.PENDING); assertThat(challenge.getDigest()).isEqualTo("rzMmotrIgsithyBYc0vgiLUEEKYx0WetQRgEF2JIozA"); assertThat(challenge.getAuthorization()).isEqualTo("pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"); + assertThat(challenge.getRRName("www.example.org")).isEqualTo("_acme-challenge.www.example.org."); + assertThat(challenge.getRRName(Identifier.dns("www.example.org"))).isEqualTo("_acme-challenge.www.example.org."); + assertThatExceptionOfType(AcmeProtocolException.class) + .isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10"))); + var response = new JSONBuilder(); challenge.prepareResponse(response); assertThatJson(response.toString()).isEqualTo("{}"); } - @Test - public void testToRRName() { - assertThat(Dns01Challenge.toRRName("www.example.org")) - .isEqualTo("_acme-challenge.www.example.org."); - assertThat(Dns01Challenge.toRRName(Identifier.dns("www.example.org"))) - .isEqualTo("_acme-challenge.www.example.org."); - assertThatExceptionOfType(AcmeProtocolException.class) - .isThrownBy(() -> Dns01Challenge.toRRName(Identifier.ip("127.0.0.10"))); - assertThat(Dns01Challenge.RECORD_NAME_PREFIX) - .isEqualTo("_acme-challenge"); - } - } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsAccount01ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsAccount01ChallengeTest.java new file mode 100644 index 00000000..11fc733b --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsAccount01ChallengeTest.java @@ -0,0 +1,61 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2025 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; + +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.exception.AcmeProtocolException; +import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.shredzone.acme4j.toolbox.TestUtils; + +/** + * Unit tests for {@link DnsAccount01Challenge}. + */ +class DnsAccount01ChallengeTest { + + private final Login login = TestUtils.login(); + + /** + * Test that {@link DnsAccount01Challenge} generates a correct authorization key. + */ + @Test + public void testDnsChallenge() { + var challenge = new DnsAccount01Challenge(login, getJSON("dnsAccount01Challenge")); + + assertThat(challenge.getType()).isEqualTo(DnsAccount01Challenge.TYPE); + assertThat(challenge.getStatus()).isEqualTo(Status.PENDING); + assertThat(challenge.getDigest()).isEqualTo("MSB8ZUQOmbNfHors7PG580PBz4f9hDuOPDN_j1bNcXI"); + assertThat(challenge.getAuthorization()).isEqualTo("ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"); + + assertThat(challenge.getRRName("www.example.org")) + .isEqualTo("_agozs7u2dml4wbyd._acme-challenge.www.example.org."); + assertThat(challenge.getRRName(Identifier.dns("www.example.org"))) + .isEqualTo("_agozs7u2dml4wbyd._acme-challenge.www.example.org."); + assertThatExceptionOfType(AcmeProtocolException.class) + .isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10"))); + + var response = new JSONBuilder(); + challenge.prepareResponse(response); + + assertThatJson(response.toString()).isEqualTo("{}"); + } + +} \ No newline at end of file diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java index 0b2daa63..e23a919a 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java @@ -32,6 +32,7 @@ import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; +import org.shredzone.acme4j.challenge.DnsAccount01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.challenge.TokenChallenge; @@ -243,13 +244,17 @@ public class AbstractAcmeProviderTest { var c2 = provider.createChallenge(login, getJSON("httpChallenge")); assertThat(c2).isNotSameAs(c1); - var c3 = provider.createChallenge(login, getJSON("dnsChallenge")); + var c3 = provider.createChallenge(login, getJSON("dns01Challenge")); assertThat(c3).isNotNull(); assertThat(c3).isInstanceOf(Dns01Challenge.class); - var c4 = provider.createChallenge(login, getJSON("tlsAlpnChallenge")); + var c4 = provider.createChallenge(login, getJSON("dnsAccount01Challenge")); assertThat(c4).isNotNull(); - assertThat(c4).isInstanceOf(TlsAlpn01Challenge.class); + assertThat(c4).isInstanceOf(DnsAccount01Challenge.class); + + var c5 = provider.createChallenge(login, getJSON("tlsAlpnChallenge")); + assertThat(c5).isNotNull(); + assertThat(c5).isInstanceOf(TlsAlpn01Challenge.class); var json6 = new JSONBuilder() .put("type", "foobar-01") diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java index e7de4352..db59ec6d 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java @@ -13,6 +13,7 @@ */ package org.shredzone.acme4j.toolbox; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -35,6 +36,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; @@ -88,6 +90,23 @@ public class AcmeUtilsTest { assertThat(base64UrlDecode).isEqualTo(sha256hash("foobar")); } + /** + * Test base32 encode. + */ + @ParameterizedTest + @CsvSource({ // Test vectors according to RFC 4648 section 10 + "'',''", + "f,MY======", + "fo,MZXQ====", + "foo,MZXW6===", + "foob,MZXW6YQ=", + "fooba,MZXW6YTB", + "foobar,MZXW6YTBOI======", + }) + public void testBase32Encode(String unencoded, String encoded) { + assertThat(base32Encode(unencoded.getBytes(UTF_8))).isEqualTo(encoded); + } + /** * Test base64 URL validation for valid values */ diff --git a/acme4j-client/src/test/resources/json/dnsChallenge.json b/acme4j-client/src/test/resources/json/dns01Challenge.json similarity index 100% rename from acme4j-client/src/test/resources/json/dnsChallenge.json rename to acme4j-client/src/test/resources/json/dns01Challenge.json diff --git a/acme4j-client/src/test/resources/json/dnsAccount01Challenge.json b/acme4j-client/src/test/resources/json/dnsAccount01Challenge.json new file mode 100644 index 00000000..5be9eefa --- /dev/null +++ b/acme4j-client/src/test/resources/json/dnsAccount01Challenge.json @@ -0,0 +1,6 @@ +{ + "type": "dns-account-01", + "url": "https://example.com/acme/chall/i00MGYwLWIx", + "status": "pending", + "token": "ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx" +} \ No newline at end of file diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java index c65d1a0d..ce2c7a40 100644 --- a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java @@ -362,12 +362,12 @@ public class ClientTest { // Output the challenge, wait for acknowledge... LOG.info("Please create a TXT record:"); LOG.info("{} IN TXT {}", - Dns01Challenge.toRRName(auth.getIdentifier()), challenge.getDigest()); + challenge.getRRName(auth.getIdentifier()), challenge.getDigest()); LOG.info("If you're ready, dismiss the dialog..."); StringBuilder message = new StringBuilder(); message.append("Please create a TXT record:\n\n"); - message.append(Dns01Challenge.toRRName(auth.getIdentifier())) + message.append(challenge.getRRName(auth.getIdentifier())) .append(" IN TXT ") .append(challenge.getDigest()); acceptChallenge(message.toString()); diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java index dfb2e605..3b85419a 100644 --- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java +++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java @@ -23,6 +23,7 @@ import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullSource; @@ -36,6 +37,7 @@ import org.shredzone.acme4j.Session; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; +import org.shredzone.acme4j.challenge.DnsAccount01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.exception.AcmeException; @@ -81,7 +83,30 @@ public class OrderIT extends PebbleITBase { var challenge = auth.findChallenge(Dns01Challenge.class).orElseThrow(); - var challengeDomainName = Dns01Challenge.toRRName(auth.getIdentifier()); + var challengeDomainName = challenge.getRRName(auth.getIdentifier()); + + client.dnsAddTxtRecord(challengeDomainName, challenge.getDigest()); + + cleanup(() -> client.dnsRemoveTxtRecord(challengeDomainName)); + + return challenge; + }, OrderIT::standardRevoker, profile); + } + + /** + * Test if a certificate can be ordered via dns-account-01 challenge. + */ + @ParameterizedTest + @NullSource + @ValueSource(strings = {"default", "shortlived"}) + @Disabled("Waiting for https://github.com/letsencrypt/pebble/pull/489") + public void testDnsAccountValidation(String profile) throws Exception { + orderCertificate(TEST_DOMAIN, auth -> { + var client = getBammBammClient(); + + var challenge = auth.findChallenge(DnsAccount01Challenge.class).orElseThrow(); + + var challengeDomainName = challenge.getRRName(auth.getIdentifier()); client.dnsAddTxtRecord(challengeDomainName, challenge.getDigest()); diff --git a/src/doc/docs/challenge/dns-01.md b/src/doc/docs/challenge/dns-01.md index 95d4e3fa..23911611 100644 --- a/src/doc/docs/challenge/dns-01.md +++ b/src/doc/docs/challenge/dns-01.md @@ -2,16 +2,15 @@ With the `dns-01` 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. -`Dns01Challenge` provides a digest string: +`Dns01Challenge` provides a resource record name and a digest string: ```java Dns01Challenge challenge = auth.findChallenge(Dns01Challenge.class); -String domain = auth.getIdentifier().getDomain(); -String resourceName = Dns01Challenge.toRRName(auth.getIdentifier()); +String resourceName = challenge.getRRName(auth.getIdentifier()); String digest = challenge.getDigest(); ``` -The CA expects a TXT record at `_acme-challenge.${domain}.` with the `digest` string as value. The `Dns01Challenge.toRRName()` method converts the domain name to a resource record name (including the trailing full stop, e.g. `_acme-challenge.www.example.org.`). The `_acme-challenge` prefix is also available as constant (`Dns01Challenge.RECORD_NAME_PREFIX`). +The CA expects a TXT record at `resourceName` with the `digest` string as value. The `Dns01Challenge.getRRName()` method converts the domain name to a resource record name (including the trailing full stop, e.g. `_acme-challenge.www.example.org.`). The validation was successful if the CA was able to fetch the TXT record and got the correct `digest` returned. diff --git a/src/doc/docs/challenge/dns-account-01.md b/src/doc/docs/challenge/dns-account-01.md new file mode 100644 index 00000000..869f721a --- /dev/null +++ b/src/doc/docs/challenge/dns-account-01.md @@ -0,0 +1,23 @@ +# dns-account-01 Challenge + +With the `dns-account-01` 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. + +This challenge is specified in [draft-ietf-acme-dns-account-label-00](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-label/). + +!!! warning + The support of this challenge is **experimental**. The implementation is only unit tested for compliance with the specification, but is not integration tested yet. There may be breaking changes in this part of the API in future releases. + +With the `dns-account-01` 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. + +`DnsAccount01Challenge` provides a digest string and a resource record name: + +```java +DnsAccount01Challenge challenge = auth.findChallenge(DnsAccount01Challenge.class); + +String resourceRecordName = challenge.getRRName(auth.getIdentifier()); +String digest = challenge.getDigest(); +``` + +The CA expects a TXT record at `resourceRecordName` with the `digest` string as value. The `getRRName()` method converts the domain name to a resource record name (including the trailing full stop). + +The validation was successful if the CA was able to fetch the TXT record and got the correct `digest` returned. diff --git a/src/doc/docs/challenge/index.md b/src/doc/docs/challenge/index.md index 426ea248..b4f01c84 100644 --- a/src/doc/docs/challenge/index.md +++ b/src/doc/docs/challenge/index.md @@ -6,10 +6,11 @@ There are different kinds of challenges. The most simple is maybe the HTTP chall The ACME specifications define these standard challenges: -* [http-01](http-01.md) * [dns-01](dns-01.md) +* [http-01](http-01.md) _acme4j_ also supports these non-standard challenges: -* [tls-alpn-01](tls-alpn-01.md) +* [dns-account-01](dns-account-01.md) ([draft-ietf-acme-dns-account-label-00](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-label/00/)) * [email-reply-00](email-reply-00.md) +* [tls-alpn-01](tls-alpn-01.md) diff --git a/src/doc/docs/example.md b/src/doc/docs/example.md index 1f61d4e9..f45b423b 100644 --- a/src/doc/docs/example.md +++ b/src/doc/docs/example.md @@ -340,12 +340,12 @@ public Challenge dnsChallenge(Authorization auth) throws AcmeException { // Output the challenge, wait for acknowledge... LOG.info("Please create a TXT record:"); LOG.info("{} IN TXT {}", - Dns01Challenge.toRRName(auth.getIdentifier()), challenge.getDigest()); + challenge.getRRName(auth.getIdentifier()), challenge.getDigest()); LOG.info("If you're ready, dismiss the dialog..."); StringBuilder message = new StringBuilder(); message.append("Please create a TXT record:\n\n"); - message.append(Dns01Challenge.toRRName(auth.getIdentifier())) + message.append(challenge.getRRName(auth.getIdentifier())) .append(" IN TXT ") .append(challenge.getDigest()); acceptChallenge(message.toString()); diff --git a/src/doc/docs/index.md b/src/doc/docs/index.md index 4ee0fca9..ca85d7e5 100644 --- a/src/doc/docs/index.md +++ b/src/doc/docs/index.md @@ -21,6 +21,7 @@ Latest version: ![maven central](https://shredzone.org/maven-central/org.shredzo * Supports [RFC 9444](https://tools.ietf.org/html/rfc9444) for subdomain validation * Supports [draft-ietf-acme-ari-07](https://www.ietf.org/archive/id/draft-ietf-acme-ari-07.html) for renewal information (experimental) * Supports [draft-aaron-acme-profiles-00](https://www.ietf.org/archive/id/draft-aaron-acme-profiles-00.html) for certificate profiles (experimental) +* Supports [draft-ietf-acme-dns-account-label-00](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-label/) for DNS labeled with ACME account ID challenges (experimental) * Easy to use Java API * Requires JRE 17 or higher * Supports [Buypass](https://buypass.com/), [Google Trust Services](https://pki.goog/), [Let's Encrypt](https://letsencrypt.org/), [SSL.com](https://www.ssl.com/), [ZeroSSL](https://zerossl.com/), and all other CAs that comply with the ACME protocol (RFC 8555). Note that _acme4j_ is an independent project that is not supported or endorsed by any of the CAs. diff --git a/src/doc/docs/migration.md b/src/doc/docs/migration.md index 2bbda282..305f51c1 100644 --- a/src/doc/docs/migration.md +++ b/src/doc/docs/migration.md @@ -5,6 +5,7 @@ This document will help you migrate your code to the latest _acme4j_ version. ## Migration to Version 3.6.0 - _acme4j_ requires JRE 17 or higher now. +- In order to keep the API consistent, the static method `Dns01Challenge.toRRName()` is replaced with a class method `Dns01Challenge.getRRName()`. The static method is marked as deprecated, but is still functional. ## Migration to Version 3.5.0 diff --git a/src/doc/mkdocs.yml b/src/doc/mkdocs.yml index f176df6f..9660fbd4 100644 --- a/src/doc/mkdocs.yml +++ b/src/doc/mkdocs.yml @@ -37,10 +37,11 @@ nav: - 'faq.md' - Challenges: - 'challenge/index.md' - - 'challenge/http-01.md' - 'challenge/dns-01.md' - - 'challenge/tls-alpn-01.md' + - 'challenge/dns-account-01.md' - 'challenge/email-reply-00.md' + - 'challenge/http-01.md' + - 'challenge/tls-alpn-01.md' - CA: - 'ca/index.md' - 'ca/buypass.md'