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

This commit is contained in:
Richard Körber
2025-04-26 12:40:03 +02:00
parent 1ed293c5bb
commit c0d96e709e
21 changed files with 361 additions and 30 deletions

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"
}