mirror of
https://github.com/shred/acme4j.git
synced 2025-12-13 11:14:02 +08:00
Add support for draft-ietf-acme-dns-account-label
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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("{}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"type": "dns-account-01",
|
||||
"url": "https://example.com/acme/chall/i00MGYwLWIx",
|
||||
"status": "pending",
|
||||
"token": "ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx"
|
||||
}
|
||||
Reference in New Issue
Block a user