From 773cacde4fe489723f637cd367e0df699d05faf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Fri, 15 Mar 2024 17:18:01 +0100 Subject: [PATCH] Add subdomain validation support (RFC 9444) --- README.md | 1 + .../java/org/shredzone/acme4j/Account.java | 5 + .../org/shredzone/acme4j/Authorization.java | 12 ++ .../java/org/shredzone/acme4j/Identifier.java | 104 ++++++++++++++---- .../java/org/shredzone/acme4j/Metadata.java | 9 ++ .../org/shredzone/acme4j/OrderBuilder.java | 7 ++ .../org/shredzone/acme4j/AccountTest.java | 73 +++++++++++- .../org/shredzone/acme4j/IdentifierTest.java | 65 ++++++++++- .../shredzone/acme4j/OrderBuilderTest.java | 82 ++++++++++++++ .../org/shredzone/acme4j/SessionTest.java | 2 + .../src/test/resources/json/directory.json | 1 + .../json/newAuthorizationRequestSub.json | 7 ++ .../json/newAuthorizationResponseSub.json | 16 +++ .../json/requestOrderRequestSub.json | 11 ++ .../json/requestOrderResponseSub.json | 17 +++ src/doc/docs/index.md | 1 + src/doc/docs/usage/advanced.md | 11 +- src/doc/docs/usage/order.md | 21 ++++ 18 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 acme4j-client/src/test/resources/json/newAuthorizationRequestSub.json create mode 100644 acme4j-client/src/test/resources/json/newAuthorizationResponseSub.json create mode 100644 acme4j-client/src/test/resources/json/requestOrderRequestSub.json create mode 100644 acme4j-client/src/test/resources/json/requestOrderResponseSub.json diff --git a/README.md b/README.md index 2c752432..deec8a04 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java index 87468502..d08aab7f 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java @@ -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(); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java index 2baf91f7..97449416 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java @@ -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. */ diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Identifier.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Identifier.java index 87bed6ac..7c0afd5a 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Identifier.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Identifier.java @@ -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 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 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(); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java index d0a108a4..c60e4a28 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java @@ -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. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java index 1cb9da84..8aab9edd 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java @@ -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(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java index c36640fc..9858967e 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java @@ -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. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/IdentifierTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/IdentifierTest.java index e0fbc3b2..988e4c8e 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/IdentifierTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/IdentifierTest.java @@ -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(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java index 3340f0e6..ed947a7a 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java @@ -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. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java index e249aba9..86ca97a2 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java @@ -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) diff --git a/acme4j-client/src/test/resources/json/directory.json b/acme4j-client/src/test/resources/json/directory.json index ae6e189d..a75c90d3 100644 --- a/acme4j-client/src/test/resources/json/directory.json +++ b/acme4j-client/src/test/resources/json/directory.json @@ -16,6 +16,7 @@ "allow-certificate-get": true }, "externalAccountRequired": true, + "subdomainAuthAllowed": true, "xTestString": "foobar", "xTestUri": "https://www.example.org", "xTestArray": [ diff --git a/acme4j-client/src/test/resources/json/newAuthorizationRequestSub.json b/acme4j-client/src/test/resources/json/newAuthorizationRequestSub.json new file mode 100644 index 00000000..62a78c6c --- /dev/null +++ b/acme4j-client/src/test/resources/json/newAuthorizationRequestSub.json @@ -0,0 +1,7 @@ +{ + "identifier": { + "type": "dns", + "value": "example.org", + "subdomainAuthAllowed": true + } +} diff --git a/acme4j-client/src/test/resources/json/newAuthorizationResponseSub.json b/acme4j-client/src/test/resources/json/newAuthorizationResponseSub.json new file mode 100644 index 00000000..372977f3 --- /dev/null +++ b/acme4j-client/src/test/resources/json/newAuthorizationResponseSub.json @@ -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 +} diff --git a/acme4j-client/src/test/resources/json/requestOrderRequestSub.json b/acme4j-client/src/test/resources/json/requestOrderRequestSub.json new file mode 100644 index 00000000..a1bdb4b8 --- /dev/null +++ b/acme4j-client/src/test/resources/json/requestOrderRequestSub.json @@ -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" +} diff --git a/acme4j-client/src/test/resources/json/requestOrderResponseSub.json b/acme4j-client/src/test/resources/json/requestOrderResponseSub.json new file mode 100644 index 00000000..37f1e41e --- /dev/null +++ b/acme4j-client/src/test/resources/json/requestOrderResponseSub.json @@ -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" +} diff --git a/src/doc/docs/index.md b/src/doc/docs/index.md index ed05bd66..7ca44f79 100644 --- a/src/doc/docs/index.md +++ b/src/doc/docs/index.md @@ -18,6 +18,7 @@ Latest version: ![maven central](https://shredzone.org/maven-central/org.shredzo * 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 diff --git a/src/doc/docs/usage/advanced.md b/src/doc/docs/usage/advanced.md index 2f4bf4d5..4de1dc55 100644 --- a/src/doc/docs/usage/advanced.md +++ b/src/doc/docs/usage/advanced.md @@ -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. diff --git a/src/doc/docs/usage/order.md b/src/doc/docs/usage/order.md index ac952186..f02c8688 100644 --- a/src/doc/docs/usage/order.md +++ b/src/doc/docs/usage/order.md @@ -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()`.