Add support for draft-ietf-acme-dns-account-label

master
Richard Körber 2025-04-26 12:40:03 +02:00
parent 1ed293c5bb
commit c0d96e709e
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
21 changed files with 361 additions and 30 deletions

View File

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

View File

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

View File

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

View File

@ -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<String, ChallengeProvider>();
map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
map.put(DnsAccount01Challenge.TYPE, DnsAccount01Challenge::new);
map.put(Http01Challenge.TYPE, Http01Challenge::new);
map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
{
"type": "dns-account-01",
"url": "https://example.com/acme/chall/i00MGYwLWIx",
"status": "pending",
"token": "ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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