Add subdomain validation support (RFC 9444)

pull/168/head
Richard Körber 2024-03-15 17:18:01 +01:00
parent b5a7e00ac3
commit 773cacde4f
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
18 changed files with 420 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@
"allow-certificate-get": true
},
"externalAccountRequired": true,
"subdomainAuthAllowed": true,
"xTestString": "foobar",
"xTestUri": "https://www.example.org",
"xTestArray": [

View File

@ -0,0 +1,7 @@
{
"identifier": {
"type": "dns",
"value": "example.org",
"subdomainAuthAllowed": true
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()`.