mirror of https://github.com/shred/acme4j
Add subdomain validation support (RFC 9444)
parent
b5a7e00ac3
commit
773cacde4f
|
@ -14,6 +14,7 @@ This Java client helps connecting to an ACME server, and performing all necessar
|
|||
* Supports [RFC 8738](https://tools.ietf.org/html/rfc8738) IP identifier validation
|
||||
* Supports [RFC 8739](https://tools.ietf.org/html/rfc8739) short-term automatic certificate renewal (experimental)
|
||||
* Supports [RFC 8823](https://tools.ietf.org/html/rfc8823) for S/MIME certificates (experimental)
|
||||
* Supports [RFC 9444](https://tools.ietf.org/html/rfc9444) for subdomain validation
|
||||
* Supports [draft-ietf-acme-ari-03](https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html) for renewal information (experimental)
|
||||
* Easy to use Java API
|
||||
* Requires JRE 11 or higher
|
||||
|
|
|
@ -189,6 +189,11 @@ public class Account extends AcmeJsonResource {
|
|||
|
||||
var newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
|
||||
|
||||
if (identifier.toMap().containsKey(Identifier.KEY_SUBDOMAIN_AUTH_ALLOWED)
|
||||
&& !getSession().getMetadata().isSubdomainAuthAllowed()) {
|
||||
throw new AcmeNotSupportedException("subdomain-auth");
|
||||
}
|
||||
|
||||
LOG.debug("preAuthorize {}", identifier);
|
||||
try (var conn = getSession().connect()) {
|
||||
var claims = new JSONBuilder();
|
||||
|
|
|
@ -83,6 +83,18 @@ public class Authorization extends AcmeJsonResource {
|
|||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if certificates for subdomains can be issued according to
|
||||
* RFC9444.
|
||||
*
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public boolean isSubdomainAuthAllowed() {
|
||||
return getJSON().get("subdomainAuthAllowed")
|
||||
.map(Value::asBoolean)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all challenges offered by the server, in no specific order.
|
||||
*/
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.Collections.unmodifiableMap;
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce;
|
||||
|
||||
|
@ -20,10 +21,10 @@ import java.io.Serializable;
|
|||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
||||
|
||||
/**
|
||||
* Represents an identifier.
|
||||
|
@ -50,11 +51,12 @@ public class Identifier implements Serializable {
|
|||
*/
|
||||
public static final String TYPE_IP = "ip";
|
||||
|
||||
private static final String KEY_TYPE = "type";
|
||||
private static final String KEY_VALUE = "value";
|
||||
static final String KEY_TYPE = "type";
|
||||
static final String KEY_VALUE = "value";
|
||||
static final String KEY_ANCESTOR_DOMAIN = "ancestorDomain";
|
||||
static final String KEY_SUBDOMAIN_AUTH_ALLOWED = "subdomainAuthAllowed";
|
||||
|
||||
private final String type;
|
||||
private final String value;
|
||||
private final Map<String, Object> content = new TreeMap<>();
|
||||
|
||||
/**
|
||||
* Creates a new {@link Identifier}.
|
||||
|
@ -71,8 +73,8 @@ public class Identifier implements Serializable {
|
|||
* Identifier value
|
||||
*/
|
||||
public Identifier(String type, String value) {
|
||||
this.type = requireNonNull(type, KEY_TYPE);
|
||||
this.value = requireNonNull(value, KEY_VALUE);
|
||||
content.put(KEY_TYPE, requireNonNull(type, KEY_TYPE));
|
||||
content.put(KEY_VALUE, requireNonNull(value, KEY_VALUE));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -82,7 +84,20 @@ public class Identifier implements Serializable {
|
|||
* {@link JSON} containing the identifier data
|
||||
*/
|
||||
public Identifier(JSON json) {
|
||||
this(json.get(KEY_TYPE).asString(), json.get(KEY_VALUE).asString());
|
||||
if (!json.contains(KEY_TYPE)) {
|
||||
throw new AcmeProtocolException("Required key " + KEY_TYPE + " is missing");
|
||||
}
|
||||
if (!json.contains(KEY_VALUE)) {
|
||||
throw new AcmeProtocolException("Required key " + KEY_VALUE + " is missing");
|
||||
}
|
||||
content.putAll(json.toMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a copy of the given Identifier.
|
||||
*/
|
||||
private Identifier(Identifier identifier) {
|
||||
content.putAll(identifier.content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -123,18 +138,50 @@ public class Identifier implements Serializable {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an ancestor domain, as required in RFC-9444.
|
||||
*
|
||||
* @param domain
|
||||
* The ancestor domain to be set. Unicode domains are automatically ASCII
|
||||
* encoded.
|
||||
* @return An {@link Identifier} that contains the ancestor domain.
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public Identifier withAncestorDomain(String domain) {
|
||||
expectType(TYPE_DNS);
|
||||
|
||||
var result = new Identifier(this);
|
||||
result.content.put(KEY_ANCESTOR_DOMAIN, toAce(domain));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the permission to authorize subdomains of this domain, as required in
|
||||
* RFC-9444.
|
||||
*
|
||||
* @return An {@link Identifier} that allows subdomain auths.
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public Identifier allowSubdomainAuth() {
|
||||
expectType(TYPE_DNS);
|
||||
|
||||
var result = new Identifier(this);
|
||||
result.content.put(KEY_SUBDOMAIN_AUTH_ALLOWED, true);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier type.
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
return content.get(KEY_TYPE).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier value.
|
||||
*/
|
||||
public String getValue() {
|
||||
return value;
|
||||
return content.get(KEY_VALUE).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -145,10 +192,8 @@ public class Identifier implements Serializable {
|
|||
* if this is not a DNS identifier.
|
||||
*/
|
||||
public String getDomain() {
|
||||
if (!TYPE_DNS.equals(type)) {
|
||||
throw new AcmeProtocolException("expected 'dns' identifier, but found '" + type + "'");
|
||||
}
|
||||
return value;
|
||||
expectType(TYPE_DNS);
|
||||
return getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -159,11 +204,9 @@ public class Identifier implements Serializable {
|
|||
* if this is not a DNS identifier.
|
||||
*/
|
||||
public InetAddress getIP() {
|
||||
if (!TYPE_IP.equals(type)) {
|
||||
throw new AcmeProtocolException("expected 'ip' identifier, but found '" + type + "'");
|
||||
}
|
||||
expectType(TYPE_IP);
|
||||
try {
|
||||
return InetAddress.getByName(value);
|
||||
return InetAddress.getByName(getValue());
|
||||
} catch (UnknownHostException ex) {
|
||||
throw new AcmeProtocolException("bad ip identifier value", ex);
|
||||
}
|
||||
|
@ -173,12 +216,29 @@ public class Identifier implements Serializable {
|
|||
* Returns the identifier as JSON map.
|
||||
*/
|
||||
public Map<String, Object> toMap() {
|
||||
return new JSONBuilder().put(KEY_TYPE, type).put(KEY_VALUE, value).toMap();
|
||||
return unmodifiableMap(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure this identifier is of the given type.
|
||||
*
|
||||
* @param type
|
||||
* Expected type
|
||||
* @throws AcmeProtocolException
|
||||
* if this identifier is of a different type
|
||||
*/
|
||||
private void expectType(String type) {
|
||||
if (!type.equals(getType())) {
|
||||
throw new AcmeProtocolException("expected '" + type + "' identifier, but found '" + getType() + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return type + "=" + value;
|
||||
if (content.size() == 2) {
|
||||
return getType() + '=' + getValue();
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -188,12 +248,12 @@ public class Identifier implements Serializable {
|
|||
}
|
||||
|
||||
var i = (Identifier) obj;
|
||||
return type.equals(i.type) && value.equals(i.value);
|
||||
return content.equals(i.content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return type.hashCode() ^ value.hashCode();
|
||||
return content.hashCode();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -131,6 +131,15 @@ public class Metadata {
|
|||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the CA supports subdomain auth according to RFC9444.
|
||||
*
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public boolean isSubdomainAuthAllowed() {
|
||||
return meta.get("subdomainAuthAllowed").map(Value::asBoolean).orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JSON representation of the metadata. This is useful for reading
|
||||
* proprietary metadata properties.
|
||||
|
|
|
@ -344,6 +344,13 @@ public class OrderBuilder {
|
|||
throw new AcmeNotSupportedException("auto-renewal");
|
||||
}
|
||||
|
||||
var hasAncestorDomain = identifierSet.stream()
|
||||
.filter(id -> Identifier.TYPE_DNS.equals(id.getType()))
|
||||
.anyMatch(id -> id.toMap().containsKey(Identifier.KEY_ANCESTOR_DOMAIN));
|
||||
if (hasAncestorDomain && !login.getSession().getMetadata().isSubdomainAuthAllowed()) {
|
||||
throw new AcmeNotSupportedException("ancestor-domain");
|
||||
}
|
||||
|
||||
LOG.debug("create");
|
||||
try (var conn = session.connect()) {
|
||||
var claims = new JSONBuilder();
|
||||
|
|
|
@ -203,7 +203,7 @@ public class AccountTest {
|
|||
var domainName = "example.org";
|
||||
|
||||
var account = new Account(login);
|
||||
var auth = account.preAuthorizeDomain(domainName);
|
||||
var auth = account.preAuthorize(Identifier.dns(domainName));
|
||||
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
|
||||
|
@ -217,6 +217,77 @@ public class AccountTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that pre-authorization with subdomains fails if not supported.
|
||||
*/
|
||||
@Test
|
||||
public void testPreAuthorizeDomainSubdomainsFails() throws Exception {
|
||||
var provider = new TestableConnectionProvider();
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
|
||||
|
||||
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
|
||||
|
||||
var account = new Account(login);
|
||||
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
|
||||
account.preAuthorize(Identifier.dns("example.org").allowSubdomainAuth())
|
||||
);
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a domain can be pre-authorized, with allowed subdomains.
|
||||
*/
|
||||
@Test
|
||||
public void testPreAuthorizeDomainSubdomains() throws Exception {
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
assertThat(url).isEqualTo(resourceUrl);
|
||||
assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequestSub").toString());
|
||||
assertThat(login).isNotNull();
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("newAuthorizationResponseSub");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putMetadata("subdomainAuthAllowed", true);
|
||||
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
|
||||
provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
|
||||
|
||||
var domainName = "example.org";
|
||||
|
||||
var account = new Account(login);
|
||||
var auth = account.preAuthorize(Identifier.dns(domainName).allowSubdomainAuth());
|
||||
|
||||
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isTrue();
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
|
||||
assertThat(auth.getExpires()).isEmpty();
|
||||
assertThat(auth.getLocation()).isEqualTo(locationUrl);
|
||||
assertThat(auth.isSubdomainAuthAllowed()).isTrue();
|
||||
|
||||
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
|
||||
provider.getChallenge(Dns01Challenge.TYPE));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a domain pre-authorization can fail.
|
||||
*/
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
@ -103,12 +103,75 @@ public class IdentifierTest {
|
|||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAncestorDomain() {
|
||||
var id1 = Identifier.dns("foo.bar.example.com");
|
||||
var id1a = id1.withAncestorDomain("example.com");
|
||||
assertThat(id1a).isNotSameAs(id1);
|
||||
assertThat(id1a.getType()).isEqualTo(Identifier.TYPE_DNS);
|
||||
assertThat(id1a.getValue()).isEqualTo("foo.bar.example.com");
|
||||
assertThat(id1a.getDomain()).isEqualTo("foo.bar.example.com");
|
||||
assertThat(id1a.toMap()).contains(
|
||||
entry("type", "dns"),
|
||||
entry("value", "foo.bar.example.com"),
|
||||
entry("ancestorDomain", "example.com")
|
||||
);
|
||||
assertThat(id1a.toString()).isEqualTo("{ancestorDomain=example.com, type=dns, value=foo.bar.example.com}");
|
||||
|
||||
var id2 = Identifier.dns("föö.ëxämþlë.com").withAncestorDomain("ëxämþlë.com");
|
||||
assertThat(id2.getType()).isEqualTo(Identifier.TYPE_DNS);
|
||||
assertThat(id2.getValue()).isEqualTo("xn--f-1gaa.xn--xml-qla7ae5k.com");
|
||||
assertThat(id2.getDomain()).isEqualTo("xn--f-1gaa.xn--xml-qla7ae5k.com");
|
||||
assertThat(id2.toMap()).contains(
|
||||
entry("type", "dns"),
|
||||
entry("value", "xn--f-1gaa.xn--xml-qla7ae5k.com"),
|
||||
entry("ancestorDomain", "xn--xml-qla7ae5k.com")
|
||||
);
|
||||
|
||||
var id3 = Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com");
|
||||
assertThat(id3.equals(id1)).isFalse();
|
||||
assertThat(id3.equals(id1a)).isTrue();
|
||||
|
||||
assertThatExceptionOfType(AcmeProtocolException.class).isThrownBy(() ->
|
||||
Identifier.ip("192.0.2.99").withAncestorDomain("example.com")
|
||||
);
|
||||
|
||||
assertThatNullPointerException().isThrownBy(() ->
|
||||
Identifier.dns("example.org").withAncestorDomain(null)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAllowSubdomainAuth() {
|
||||
var id1 = Identifier.dns("example.com");
|
||||
var id1a = id1.allowSubdomainAuth();
|
||||
assertThat(id1a).isNotSameAs(id1);
|
||||
assertThat(id1a.getType()).isEqualTo(Identifier.TYPE_DNS);
|
||||
assertThat(id1a.getValue()).isEqualTo("example.com");
|
||||
assertThat(id1a.getDomain()).isEqualTo("example.com");
|
||||
assertThat(id1a.toMap()).contains(
|
||||
entry("type", "dns"),
|
||||
entry("value", "example.com"),
|
||||
entry("subdomainAuthAllowed", true)
|
||||
);
|
||||
assertThat(id1a.toString()).isEqualTo("{subdomainAuthAllowed=true, type=dns, value=example.com}");
|
||||
|
||||
var id3 = Identifier.dns("example.com").allowSubdomainAuth();
|
||||
assertThat(id3.equals(id1)).isFalse();
|
||||
assertThat(id3.equals(id1a)).isTrue();
|
||||
|
||||
assertThatExceptionOfType(AcmeProtocolException.class).isThrownBy(() ->
|
||||
Identifier.ip("192.0.2.99").allowSubdomainAuth()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEquals() {
|
||||
var idRef = new Identifier("foo", "123.456");
|
||||
|
||||
var id1 = new Identifier("foo", "123.456");
|
||||
assertThat(idRef.equals(id1)).isTrue();
|
||||
assertThat(id1.equals(idRef)).isTrue();
|
||||
|
||||
var id2 = new Identifier("bar", "654.321");
|
||||
assertThat(idRef.equals(id2)).isFalse();
|
||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j;
|
|||
|
||||
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.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
|
||||
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
|
||||
|
@ -187,6 +188,87 @@ public class OrderBuilderTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a new {@link Order} with ancestor domain can be created.
|
||||
*/
|
||||
@Test
|
||||
public void testOrderCertificateWithAncestor() throws Exception {
|
||||
var notBefore = parseTimestamp("2016-01-01T00:00:00Z");
|
||||
var notAfter = parseTimestamp("2016-01-08T00:00:00Z");
|
||||
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
assertThat(url).isEqualTo(resourceUrl);
|
||||
assertThatJson(claims.toString()).isEqualTo(getJSON("requestOrderRequestSub").toString());
|
||||
assertThat(login).isNotNull();
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("requestOrderResponseSub");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
provider.putMetadata("subdomainAuthAllowed", true);
|
||||
|
||||
var account = new Account(login);
|
||||
var order = account.newOrder()
|
||||
.identifier(Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com"))
|
||||
.notBefore(notBefore)
|
||||
.notAfter(notAfter)
|
||||
.create();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(
|
||||
Identifier.dns("foo.bar.example.com"));
|
||||
softly.assertThat(order.getNotBefore().orElseThrow())
|
||||
.isEqualTo("2016-01-01T00:10:00Z");
|
||||
softly.assertThat(order.getNotAfter().orElseThrow())
|
||||
.isEqualTo("2016-01-08T00:10:00Z");
|
||||
softly.assertThat(order.getExpires().orElseThrow())
|
||||
.isEqualTo("2016-01-10T00:00:00Z");
|
||||
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
|
||||
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
|
||||
softly.assertThat(order.getAuthorizations()).isNotNull();
|
||||
softly.assertThat(order.getAuthorizations()).hasSize(2);
|
||||
}
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a new {@link Order} with ancestor domain fails if not supported.
|
||||
*/
|
||||
@Test
|
||||
public void testOrderCertificateWithAncestorFails() throws Exception {
|
||||
var provider = new TestableConnectionProvider();
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
|
||||
|
||||
var account = new Account(login);
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
|
||||
account.newOrder()
|
||||
.identifier(Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com"))
|
||||
.create()
|
||||
);
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that an auto-renewal {@link Order} cannot be created if unsupported by the CA.
|
||||
*/
|
||||
|
|
|
@ -184,6 +184,7 @@ public class SessionTest {
|
|||
softly.assertThat(meta.getAutoRenewalMinLifetime()).isEqualTo(Duration.ofHours(24));
|
||||
softly.assertThat(meta.isAutoRenewalGetAllowed()).isTrue();
|
||||
softly.assertThat(meta.isExternalAccountRequired()).isTrue();
|
||||
softly.assertThat(meta.isSubdomainAuthAllowed()).isTrue();
|
||||
softly.assertThat(meta.getJSON()).isNotNull();
|
||||
}
|
||||
|
||||
|
@ -227,6 +228,7 @@ public class SessionTest {
|
|||
softly.assertThat(meta.getWebsite()).isEmpty();
|
||||
softly.assertThat(meta.getCaaIdentities()).isEmpty();
|
||||
softly.assertThat(meta.isAutoRenewalEnabled()).isFalse();
|
||||
softly.assertThat(meta.isSubdomainAuthAllowed()).isFalse();
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(meta::getAutoRenewalMaxDuration);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"allow-certificate-get": true
|
||||
},
|
||||
"externalAccountRequired": true,
|
||||
"subdomainAuthAllowed": true,
|
||||
"xTestString": "foobar",
|
||||
"xTestUri": "https://www.example.org",
|
||||
"xTestArray": [
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"identifier": {
|
||||
"type": "dns",
|
||||
"value": "example.org",
|
||||
"subdomainAuthAllowed": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"status": "pending",
|
||||
"identifier": {
|
||||
"type": "dns",
|
||||
"value": "example.org"
|
||||
},
|
||||
"challenges": [
|
||||
{
|
||||
"type": "dns-01",
|
||||
"status": "pending",
|
||||
"url": "https://example.com/authz/asdf/1",
|
||||
"token": "DGyRejmCefe7v4NfDGDKfA"
|
||||
}
|
||||
],
|
||||
"subdomainAuthAllowed": true
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"identifiers": [
|
||||
{
|
||||
"type": "dns",
|
||||
"value": "foo.bar.example.com",
|
||||
"ancestorDomain": "example.com"
|
||||
},
|
||||
],
|
||||
"notBefore": "2016-01-01T00:00:00Z",
|
||||
"notAfter": "2016-01-08T00:00:00Z"
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"status": "pending",
|
||||
"expires": "2016-01-10T00:00:00Z",
|
||||
"identifiers": [
|
||||
{
|
||||
"type": "dns",
|
||||
"value": "foo.bar.example.com"
|
||||
}
|
||||
],
|
||||
"notBefore": "2016-01-01T00:10:00Z",
|
||||
"notAfter": "2016-01-08T00:10:00Z",
|
||||
"authorizations": [
|
||||
"https://example.com/acme/authz/1234",
|
||||
"https://example.com/acme/authz/2345"
|
||||
],
|
||||
"finalize": "https://example.com/acme/acct/1/order/1/finalize"
|
||||
}
|
|
@ -18,6 +18,7 @@ Latest version:  IP identifier validation
|
||||
* Supports [RFC 8739](https://tools.ietf.org/html/rfc8739) short-term automatic certificate renewal (experimental)
|
||||
* Supports [RFC 8823](https://tools.ietf.org/html/rfc8823) for S/MIME certificates (experimental)
|
||||
* Supports [RFC 9444](https://tools.ietf.org/html/rfc9444) for subdomain validation
|
||||
* Supports [draft-ietf-acme-ari-03](https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html) for renewal information (experimental)
|
||||
* Easy to use Java API
|
||||
* Requires JRE 11 or higher
|
||||
|
|
|
@ -50,12 +50,21 @@ It is possible to pro-actively authorize a domain, without ordering a certificat
|
|||
Account account = ... // your Account object
|
||||
String domain = ... // Domain name to authorize
|
||||
|
||||
Authorization auth = account.preAuthorizeDomain(domain);
|
||||
Authorization auth = account.preAuthorize(Identifier.dns(domain));
|
||||
```
|
||||
|
||||
!!! note
|
||||
Some CAs may not offer domain pre-authorization, `preAuthorizeDomain()` will then fail and throw an `AcmeNotSupportedException`. Some CAs may limit pre-authorization to certain domain types (e.g. non-wildcard) and throw an `AcmeServerException` otherwise.
|
||||
|
||||
To pre-authorize a domain for subdomain certificates as specified in [RFC 9444](https://tools.ietf.org/html/rfc9444), flag the `Identifier` accordingly using `allowSubdomainAuth()`:
|
||||
|
||||
```java
|
||||
Account account = ... // your Account object
|
||||
String domain = ... // Domain name to authorize
|
||||
|
||||
Authorization auth = account.preAuthorize(Identifier.dns(domain).allowSubdomainAuth());
|
||||
```
|
||||
|
||||
## Localized Error Messages
|
||||
|
||||
By default, _acme4j_ will send your system's default locale as `Accept-Language` header to the CA (with a fallback to any other language). If the language is supported by the CA, it will return localized error messages.
|
||||
|
|
|
@ -219,3 +219,24 @@ Order order = account.newOrder()
|
|||
```
|
||||
|
||||
The example also shows how to add domain names as DNS `Identifier` objects. Adding domain names via `domain()` is just a shortcut notation for it.
|
||||
|
||||
## Subdomains
|
||||
|
||||
Ordering certificates for subdomains is not different to ordering certificates for domains. You prove ownership of that subdomain, and then get a certificate for it.
|
||||
|
||||
If your CA supports [RFC 9444](https://tools.ietf.org/html/rfc9444), you can also get certificates for all subdomains only by proving ownership of an ancestor domain. To do so, add the ancestor domain to your `Identifier` when creating the order:
|
||||
|
||||
```java
|
||||
Order order = account.newOrder()
|
||||
.identifier(
|
||||
Identifier.dns("foo.bar.example.org")
|
||||
.withAncestorDomain("example.org")
|
||||
)
|
||||
.create();
|
||||
```
|
||||
|
||||
The CA can then choose to issue challenges for any of `foo.bar.example.org`, `bar.example.org`, or `example.org`. For each challenge, the related domain can be get via `Authorization.getIdentifier()`.
|
||||
|
||||
`Authorization.isSubdomainAuthAllowed()` will return `true` if that `Authorization` is used to issue subdomain certificates.
|
||||
|
||||
To check if your CA supports RFC 9444, read `Metadata.isSubdomainAuthAllowed()`.
|
||||
|
|
Loading…
Reference in New Issue