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 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 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 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)
|
* 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
|
* Easy to use Java API
|
||||||
* Requires JRE 11 or higher
|
* Requires JRE 11 or higher
|
||||||
|
|
|
@ -189,6 +189,11 @@ public class Account extends AcmeJsonResource {
|
||||||
|
|
||||||
var newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
|
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);
|
LOG.debug("preAuthorize {}", identifier);
|
||||||
try (var conn = getSession().connect()) {
|
try (var conn = getSession().connect()) {
|
||||||
var claims = new JSONBuilder();
|
var claims = new JSONBuilder();
|
||||||
|
|
|
@ -83,6 +83,18 @@ public class Authorization extends AcmeJsonResource {
|
||||||
.orElse(false);
|
.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.
|
* Gets a list of all challenges offered by the server, in no specific order.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
*/
|
*/
|
||||||
package org.shredzone.acme4j;
|
package org.shredzone.acme4j;
|
||||||
|
|
||||||
|
import static java.util.Collections.unmodifiableMap;
|
||||||
import static java.util.Objects.requireNonNull;
|
import static java.util.Objects.requireNonNull;
|
||||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce;
|
import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce;
|
||||||
|
|
||||||
|
@ -20,10 +21,10 @@ import java.io.Serializable;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||||
import org.shredzone.acme4j.toolbox.JSON;
|
import org.shredzone.acme4j.toolbox.JSON;
|
||||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an identifier.
|
* Represents an identifier.
|
||||||
|
@ -50,11 +51,12 @@ public class Identifier implements Serializable {
|
||||||
*/
|
*/
|
||||||
public static final String TYPE_IP = "ip";
|
public static final String TYPE_IP = "ip";
|
||||||
|
|
||||||
private static final String KEY_TYPE = "type";
|
static final String KEY_TYPE = "type";
|
||||||
private static final String KEY_VALUE = "value";
|
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 Map<String, Object> content = new TreeMap<>();
|
||||||
private final String value;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new {@link Identifier}.
|
* Creates a new {@link Identifier}.
|
||||||
|
@ -71,8 +73,8 @@ public class Identifier implements Serializable {
|
||||||
* Identifier value
|
* Identifier value
|
||||||
*/
|
*/
|
||||||
public Identifier(String type, String value) {
|
public Identifier(String type, String value) {
|
||||||
this.type = requireNonNull(type, KEY_TYPE);
|
content.put(KEY_TYPE, requireNonNull(type, KEY_TYPE));
|
||||||
this.value = requireNonNull(value, KEY_VALUE);
|
content.put(KEY_VALUE, requireNonNull(value, KEY_VALUE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -82,7 +84,20 @@ public class Identifier implements Serializable {
|
||||||
* {@link JSON} containing the identifier data
|
* {@link JSON} containing the identifier data
|
||||||
*/
|
*/
|
||||||
public Identifier(JSON json) {
|
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.
|
* Returns the identifier type.
|
||||||
*/
|
*/
|
||||||
public String getType() {
|
public String getType() {
|
||||||
return type;
|
return content.get(KEY_TYPE).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the identifier value.
|
* Returns the identifier value.
|
||||||
*/
|
*/
|
||||||
public String getValue() {
|
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.
|
* if this is not a DNS identifier.
|
||||||
*/
|
*/
|
||||||
public String getDomain() {
|
public String getDomain() {
|
||||||
if (!TYPE_DNS.equals(type)) {
|
expectType(TYPE_DNS);
|
||||||
throw new AcmeProtocolException("expected 'dns' identifier, but found '" + type + "'");
|
return getValue();
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -159,11 +204,9 @@ public class Identifier implements Serializable {
|
||||||
* if this is not a DNS identifier.
|
* if this is not a DNS identifier.
|
||||||
*/
|
*/
|
||||||
public InetAddress getIP() {
|
public InetAddress getIP() {
|
||||||
if (!TYPE_IP.equals(type)) {
|
expectType(TYPE_IP);
|
||||||
throw new AcmeProtocolException("expected 'ip' identifier, but found '" + type + "'");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return InetAddress.getByName(value);
|
return InetAddress.getByName(getValue());
|
||||||
} catch (UnknownHostException ex) {
|
} catch (UnknownHostException ex) {
|
||||||
throw new AcmeProtocolException("bad ip identifier value", ex);
|
throw new AcmeProtocolException("bad ip identifier value", ex);
|
||||||
}
|
}
|
||||||
|
@ -173,12 +216,29 @@ public class Identifier implements Serializable {
|
||||||
* Returns the identifier as JSON map.
|
* Returns the identifier as JSON map.
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> toMap() {
|
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
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return type + "=" + value;
|
if (content.size() == 2) {
|
||||||
|
return getType() + '=' + getValue();
|
||||||
|
}
|
||||||
|
return content.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -188,12 +248,12 @@ public class Identifier implements Serializable {
|
||||||
}
|
}
|
||||||
|
|
||||||
var i = (Identifier) obj;
|
var i = (Identifier) obj;
|
||||||
return type.equals(i.type) && value.equals(i.value);
|
return content.equals(i.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return type.hashCode() ^ value.hashCode();
|
return content.hashCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,6 +131,15 @@ public class Metadata {
|
||||||
.orElse(false);
|
.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
|
* Returns the JSON representation of the metadata. This is useful for reading
|
||||||
* proprietary metadata properties.
|
* proprietary metadata properties.
|
||||||
|
|
|
@ -344,6 +344,13 @@ public class OrderBuilder {
|
||||||
throw new AcmeNotSupportedException("auto-renewal");
|
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");
|
LOG.debug("create");
|
||||||
try (var conn = session.connect()) {
|
try (var conn = session.connect()) {
|
||||||
var claims = new JSONBuilder();
|
var claims = new JSONBuilder();
|
||||||
|
|
|
@ -203,7 +203,7 @@ public class AccountTest {
|
||||||
var domainName = "example.org";
|
var domainName = "example.org";
|
||||||
|
|
||||||
var account = new Account(login);
|
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.getIdentifier().getDomain()).isEqualTo(domainName);
|
||||||
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
|
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
|
||||||
|
@ -217,6 +217,77 @@ public class AccountTest {
|
||||||
provider.close();
|
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.
|
* Test that a domain pre-authorization can fail.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
*/
|
*/
|
||||||
package org.shredzone.acme4j;
|
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 static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
import java.net.InetAddress;
|
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
|
@Test
|
||||||
public void testEquals() {
|
public void testEquals() {
|
||||||
var idRef = new Identifier("foo", "123.456");
|
var idRef = new Identifier("foo", "123.456");
|
||||||
|
|
||||||
var id1 = new Identifier("foo", "123.456");
|
var id1 = new Identifier("foo", "123.456");
|
||||||
assertThat(idRef.equals(id1)).isTrue();
|
assertThat(idRef.equals(id1)).isTrue();
|
||||||
|
assertThat(id1.equals(idRef)).isTrue();
|
||||||
|
|
||||||
var id2 = new Identifier("bar", "654.321");
|
var id2 = new Identifier("bar", "654.321");
|
||||||
assertThat(idRef.equals(id2)).isFalse();
|
assertThat(idRef.equals(id2)).isFalse();
|
||||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j;
|
||||||
|
|
||||||
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
|
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
|
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
|
||||||
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
|
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
|
||||||
|
@ -187,6 +188,87 @@ public class OrderBuilderTest {
|
||||||
provider.close();
|
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.
|
* 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.getAutoRenewalMinLifetime()).isEqualTo(Duration.ofHours(24));
|
||||||
softly.assertThat(meta.isAutoRenewalGetAllowed()).isTrue();
|
softly.assertThat(meta.isAutoRenewalGetAllowed()).isTrue();
|
||||||
softly.assertThat(meta.isExternalAccountRequired()).isTrue();
|
softly.assertThat(meta.isExternalAccountRequired()).isTrue();
|
||||||
|
softly.assertThat(meta.isSubdomainAuthAllowed()).isTrue();
|
||||||
softly.assertThat(meta.getJSON()).isNotNull();
|
softly.assertThat(meta.getJSON()).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,6 +228,7 @@ public class SessionTest {
|
||||||
softly.assertThat(meta.getWebsite()).isEmpty();
|
softly.assertThat(meta.getWebsite()).isEmpty();
|
||||||
softly.assertThat(meta.getCaaIdentities()).isEmpty();
|
softly.assertThat(meta.getCaaIdentities()).isEmpty();
|
||||||
softly.assertThat(meta.isAutoRenewalEnabled()).isFalse();
|
softly.assertThat(meta.isAutoRenewalEnabled()).isFalse();
|
||||||
|
softly.assertThat(meta.isSubdomainAuthAllowed()).isFalse();
|
||||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||||
.isThrownBy(meta::getAutoRenewalMaxDuration);
|
.isThrownBy(meta::getAutoRenewalMaxDuration);
|
||||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"allow-certificate-get": true
|
"allow-certificate-get": true
|
||||||
},
|
},
|
||||||
"externalAccountRequired": true,
|
"externalAccountRequired": true,
|
||||||
|
"subdomainAuthAllowed": true,
|
||||||
"xTestString": "foobar",
|
"xTestString": "foobar",
|
||||||
"xTestUri": "https://www.example.org",
|
"xTestUri": "https://www.example.org",
|
||||||
"xTestArray": [
|
"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 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 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 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)
|
* 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
|
* Easy to use Java API
|
||||||
* Requires JRE 11 or higher
|
* 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
|
Account account = ... // your Account object
|
||||||
String domain = ... // Domain name to authorize
|
String domain = ... // Domain name to authorize
|
||||||
|
|
||||||
Authorization auth = account.preAuthorizeDomain(domain);
|
Authorization auth = account.preAuthorize(Identifier.dns(domain));
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
!!! 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.
|
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
|
## 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.
|
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.
|
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