Support the acme-star extension

pull/81/head
Richard Körber 2018-08-22 18:39:13 +02:00
parent f609a797cb
commit d0d93b855a
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
20 changed files with 592 additions and 4 deletions

View File

@ -13,6 +13,7 @@ It is an independent open source implementation that is not affiliated with or e
* Fully supports the ACME v2 protocol up to [draft 13](https://tools.ietf.org/html/draft-ietf-acme-acme-13) * Fully supports the ACME v2 protocol up to [draft 13](https://tools.ietf.org/html/draft-ietf-acme-acme-13)
* Supports all ACME challenges and the `tls-alpn-01` challenge * Supports all ACME challenges and the `tls-alpn-01` challenge
* Supports the [acme-ip draft](https://tools.ietf.org/html/draft-ietf-acme-ip) * Supports the [acme-ip draft](https://tools.ietf.org/html/draft-ietf-acme-ip)
* Supports the [acme-star draft](https://tools.ietf.org/html/draft-ietf-acme-star) for short-term automatic certificate renewal (experimental)
* Easy to use Java API * Easy to use Java API
* Requires JRE 8 (update 101) or higher * Requires JRE 8 (update 101) or higher
* Built with maven, packages available at [Maven Central](http://search.maven.org/#search|ga|1|g%3A%22org.shredzone.acme4j%22) * Built with maven, packages available at [Maven Central](http://search.maven.org/#search|ga|1|g%3A%22org.shredzone.acme4j%22)

View File

@ -17,6 +17,7 @@ import static java.util.stream.Collectors.toList;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.util.Collection; import java.util.Collection;
import javax.annotation.CheckForNull; import javax.annotation.CheckForNull;
@ -82,6 +83,36 @@ public class Metadata {
return meta.get("externalAccountRequired").map(Value::asBoolean).orElse(false); return meta.get("externalAccountRequired").map(Value::asBoolean).orElse(false);
} }
/**
* Returns whether the CA supports short-term auto renewal of certificates.
*
* @since 2.3
*/
public boolean isStarEnabled() {
return meta.get("star-enabled").map(Value::asBoolean).orElse(false);
}
/**
* Returns the minimum acceptable value for the maximum validity of a certificate
* before auto renewal. {@code null} if the CA does not support short-term auto
* renewal.
*
* @since 2.3
*/
public Duration getStarMinCertValidity() {
return meta.get("star-min-cert-validity").map(Value::asDuration).orElse(null);
}
/**
* Returns the maximum delta between recurrent end date and recurrent start date.
* {@code null} if the CA does not support short-term auto renewal.
*
* @since 2.3
*/
public Duration getStarMaxRenewal() {
return meta.get("star-max-renewal").map(Value::asDuration).orElse(null);
}
/** /**
* 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.

View File

@ -16,6 +16,7 @@ package org.shredzone.acme4j;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -25,6 +26,7 @@ import javax.annotation.ParametersAreNonnullByDefault;
import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSON.Value;
import org.shredzone.acme4j.toolbox.JSONBuilder; import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -169,4 +171,81 @@ public class Order extends AcmeJsonResource {
invalidate(); invalidate();
} }
/**
* Checks if this order is recurrent, according to the ACME STAR specifications.
*
* @since 2.3
*/
public boolean isRecurrent() {
return getJSON().get("recurrent")
.optional()
.map(Value::asBoolean)
.orElse(false);
}
/**
* Returns the earliest date of validity of the first certificate issued, or
* {@code null}.
*
* @since 2.3
*/
@CheckForNull
public Instant getRecurrentStart() {
return getJSON().get("recurrent-start-date")
.optional()
.map(Value::asInstant)
.orElse(null);
}
/**
* Returns the latest date of validity of the last certificate issued, or
* {@code null}.
*
* @since 2.3
*/
@CheckForNull
public Instant getRecurrentEnd() {
return getJSON().get("recurrent-end-date")
.optional()
.map(Value::asInstant)
.orElse(null);
}
/**
* Returns the maximum validity period of each certificate, or {@code null}.
*
* @since 2.3
*/
@CheckForNull
public Duration getRecurrentCertificateValidity() {
return getJSON().get("recurrent-certificate-validity")
.optional()
.map(Value::asDuration)
.orElse(null);
}
/**
* Cancels a recurrent order.
*
* @since 2.3
*/
public void cancelRecurrent() throws AcmeException {
if (!getSession().getMetadata().isStarEnabled()) {
throw new AcmeException("CA does not support short-term automatic renewals");
}
LOG.debug("cancel");
try (Connection conn = connect()) {
JSONBuilder claims = new JSONBuilder();
claims.put("status", "canceled");
conn.sendSignedRequest(getLocation(), claims, getLogin());
JSON json = conn.readJsonResponse();
if (json != null) {
setJSON(json);
}
}
}
} }

View File

@ -17,6 +17,7 @@ import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@ -45,6 +46,10 @@ public class OrderBuilder {
private final Set<Identifier> identifierSet = new LinkedHashSet<>(); private final Set<Identifier> identifierSet = new LinkedHashSet<>();
private Instant notBefore; private Instant notBefore;
private Instant notAfter; private Instant notAfter;
private boolean recurrent;
private Instant recurrentStart;
private Instant recurrentEnd;
private Duration recurrentValidity;
/** /**
* Create a new {@link OrderBuilder}. * Create a new {@link OrderBuilder}.
@ -131,6 +136,9 @@ public class OrderBuilder {
* @return itself * @return itself
*/ */
public OrderBuilder notBefore(Instant notBefore) { public OrderBuilder notBefore(Instant notBefore) {
if (recurrent) {
throw new IllegalArgumentException("cannot combine notBefore with recurrent");
}
this.notBefore = requireNonNull(notBefore, "notBefore"); this.notBefore = requireNonNull(notBefore, "notBefore");
return this; return this;
} }
@ -142,10 +150,84 @@ public class OrderBuilder {
* @return itself * @return itself
*/ */
public OrderBuilder notAfter(Instant notAfter) { public OrderBuilder notAfter(Instant notAfter) {
if (recurrent) {
throw new IllegalArgumentException("cannot combine notAfter with recurrent");
}
this.notAfter = requireNonNull(notAfter, "notAfter"); this.notAfter = requireNonNull(notAfter, "notAfter");
return this; return this;
} }
/**
* Enables short-term automatic renewal of the certificate. Must be supported by the
* CA.
* <p>
* Recurrent renewals cannot be combined with {@link #notBefore(Instant)} or
* {@link #notAfter(Instant)}.
*
* @return itself
* @since 2.3
*/
public OrderBuilder recurrent() {
if (notBefore != null || notAfter != null) {
throw new IllegalArgumentException("cannot combine notBefore/notAfter with recurrent");
}
this.recurrent = true;
return this;
}
/**
* Sets the earliest date of validity of the first issued certificate. If not set,
* the start date is the earliest possible date.
* <p>
* Implies {@link #recurrent()}.
*
* @param start
* Start date of validity
* @return itself
* @since 2.3
*/
public OrderBuilder recurrentStart(Instant start) {
recurrent();
this.recurrentStart = requireNonNull(start, "start");
return this;
}
/**
* Sets the latest date of validity of the last issued certificate. If not set, the
* CA's default is used.
* <p>
* Implies {@link #recurrent()}.
*
* @param end
* End date of validity
* @return itself
* @see Metadata#getStarMaxRenewal()
* @since 2.3
*/
public OrderBuilder recurrentEnd(Instant end) {
recurrent();
this.recurrentEnd = requireNonNull(end, "end");
return this;
}
/**
* Sets the maximum validity period of each certificate. If not set, the CA's
* default is used.
* <p>
* Implies {@link #recurrent()}.
*
* @param duration
* Duration of validity of each certificate
* @return itself
* @see Metadata#getStarMinCertValidity()
* @since 2.3
*/
public OrderBuilder recurrentCertificateValidity(Duration duration) {
recurrent();
this.recurrentValidity = requireNonNull(duration, "duration");
return this;
}
/** /**
* Sends a new order to the server, and returns an {@link Order} object. * Sends a new order to the server, and returns an {@link Order} object.
* *
@ -158,6 +240,10 @@ public class OrderBuilder {
Session session = login.getSession(); Session session = login.getSession();
if (recurrent && !session.getMetadata().isStarEnabled()) {
throw new AcmeException("CA does not support short-term automatic renewals");
}
LOG.debug("create"); LOG.debug("create");
try (Connection conn = session.provider().connect()) { try (Connection conn = session.provider().connect()) {
JSONBuilder claims = new JSONBuilder(); JSONBuilder claims = new JSONBuilder();
@ -170,6 +256,19 @@ public class OrderBuilder {
claims.put("notAfter", notAfter); claims.put("notAfter", notAfter);
} }
if (recurrent) {
claims.put("recurrent", true);
if (recurrentStart != null) {
claims.put("recurrent-start-date", recurrentStart);
}
if (recurrentStart != null) {
claims.put("recurrent-end-date", recurrentEnd);
}
if (recurrentValidity != null) {
claims.put("recurrent-certificate-validity", recurrentValidity);
}
}
conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, login); conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, login);
URL orderLocation = conn.getLocation(); URL orderLocation = conn.getLocation();

View File

@ -67,6 +67,13 @@ public enum Status {
*/ */
EXPIRED, EXPIRED,
/**
* A recurrent {@link Order} is canceled.
*
* @since 2.3
*/
CANCELED,
/** /**
* The server did not provide a status, or the provided status is not a specified ACME * The server did not provide a status, or the provided status is not a specified ACME
* status. * status.

View File

@ -27,6 +27,7 @@ import java.net.MalformedURLException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -438,6 +439,20 @@ public final class JSON implements Serializable {
} }
} }
/**
* Returns the value as {@link Duration}.
*
* @since 2.3
*/
public Duration asDuration() {
required();
try {
return Duration.ofSeconds(((Number) val).longValue());
} catch (ClassCastException ex) {
throw new AcmeProtocolException(path + ": bad duration " + val, ex);
}
}
/** /**
* Returns the value as base64 decoded byte array. * Returns the value as base64 decoded byte array.
*/ */

View File

@ -17,6 +17,7 @@ import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode;
import java.security.Key; import java.security.Key;
import java.security.PublicKey; import java.security.PublicKey;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Collection; import java.util.Collection;
@ -84,6 +85,27 @@ public class JSONBuilder {
return this; return this;
} }
/**
* Puts a {@link Duration} to the JSON. If a property with the key exists, it will be
* replaced.
*
* @param key
* Property key
* @param value
* Property {@link Duration} value
* @return {@code this}
* @since 2.3
*/
public JSONBuilder put(String key, @Nullable Duration value) {
if (value == null) {
put(key, (Object) null);
return this;
}
put(key, value.getSeconds());
return this;
}
/** /**
* Puts binary data to the JSON. The data is base64 url encoded. * Puts binary data to the JSON. The data is base64 url encoded.
* *

View File

@ -14,7 +14,7 @@
package org.shredzone.acme4j; package org.shredzone.acme4j;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat; import static org.junit.Assert.*;
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp; import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
import static org.shredzone.acme4j.toolbox.TestUtils.*; import static org.shredzone.acme4j.toolbox.TestUtils.*;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
@ -22,11 +22,13 @@ import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import org.junit.Test; import org.junit.Test;
import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder; import org.shredzone.acme4j.toolbox.JSONBuilder;
@ -112,4 +114,141 @@ public class OrderBuilderTest {
provider.close(); provider.close();
} }
/**
* Test that a new recurrent {@link Order} can be created.
*/
@Test
public void testRecurrentOrderCertificate() throws Exception {
Instant recurrentStart = parseTimestamp("2018-01-01T00:00:00Z");
Instant recurrentEnd = parseTimestamp("2019-01-01T00:00:00Z");
Duration validity = Duration.ofDays(7);
TestableConnectionProvider provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url, is(resourceUrl));
assertThat(claims.toString(), sameJSONAs(getJSON("requestRecurrentOrderRequest").toString()));
assertThat(login, is(notNullValue()));
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("requestRecurrentOrderResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
Login login = provider.createLogin();
provider.putMetadata("star-enabled", true);
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
Account account = new Account(login);
Order order = account.newOrder()
.domain("example.org")
.recurrent()
.recurrentStart(recurrentStart)
.recurrentEnd(recurrentEnd)
.recurrentCertificateValidity(validity)
.create();
assertThat(order.getIdentifiers(), containsInAnyOrder(Identifier.dns("example.org")));
assertThat(order.getNotBefore(), is(nullValue()));
assertThat(order.getNotAfter(), is(nullValue()));
assertThat(order.isRecurrent(), is(true));
assertThat(order.getRecurrentStart(), is(recurrentStart));
assertThat(order.getRecurrentEnd(), is(recurrentEnd));
assertThat(order.getRecurrentCertificateValidity(), is(validity));
assertThat(order.getLocation(), is(locationUrl));
provider.close();
}
/**
* Test that a recurrent {@link Order} cannot be created if unsupported by the CA.
*/
@Test(expected = AcmeException.class)
public void testRecurrentOrderCertificateFails() throws Exception {
TestableConnectionProvider provider = new TestableConnectionProvider();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
Login login = provider.createLogin();
Account account = new Account(login);
account.newOrder()
.domain("example.org")
.recurrent()
.create();
provider.close();
}
/**
* Test that recurrent and notBefore/notAfter cannot be mixed.
*/
@Test
public void testRecurrentNotMixed() throws Exception {
Instant someInstant = parseTimestamp("2018-01-01T00:00:00Z");
TestableConnectionProvider provider = new TestableConnectionProvider();
Login login = provider.createLogin();
Account account = new Account(login);
try {
OrderBuilder ob = account.newOrder().recurrent();
ob.notBefore(someInstant);
fail("accepted notBefore");
} catch (IllegalArgumentException ex) {
// expected
}
try {
OrderBuilder ob = account.newOrder().recurrent();
ob.notAfter(someInstant);
fail("accepted notAfter");
} catch (IllegalArgumentException ex) {
// expected
}
try {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.recurrent();
fail("accepted recurrent");
} catch (IllegalArgumentException ex) {
// expected
}
try {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.recurrentStart(someInstant);
fail("accepted recurrentStart");
} catch (IllegalArgumentException ex) {
// expected
}
try {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.recurrentEnd(someInstant);
fail("accepted recurrentEnd");
} catch (IllegalArgumentException ex) {
// expected
}
try {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.recurrentCertificateValidity(Duration.ofDays(7));
fail("accepted recurrentCertificateValidity");
} catch (IllegalArgumentException ex) {
// expected
}
provider.close();
}
} }

View File

@ -22,6 +22,7 @@ import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@ -80,6 +81,11 @@ public class OrderTest {
assertThat(order.getCertificate().getLocation(), is(url("https://example.com/acme/cert/1234"))); assertThat(order.getCertificate().getLocation(), is(url("https://example.com/acme/cert/1234")));
assertThat(order.getFinalizeLocation(), is(finalizeUrl)); assertThat(order.getFinalizeLocation(), is(finalizeUrl));
assertThat(order.isRecurrent(), is(false));
assertThat(order.getRecurrentStart(), is(nullValue()));
assertThat(order.getRecurrentEnd(), is(nullValue()));
assertThat(order.getRecurrentCertificateValidity(), is(nullValue()));
assertThat(order.getError(), is(notNullValue())); assertThat(order.getError(), is(notNullValue()));
assertThat(order.getError().getType(), is(URI.create("urn:ietf:params:acme:error:connection"))); assertThat(order.getError().getType(), is(URI.create("urn:ietf:params:acme:error:connection")));
assertThat(order.getError().getDetail(), is("connection refused")); assertThat(order.getError().getDetail(), is("connection refused"));
@ -202,4 +208,76 @@ public class OrderTest {
provider.close(); provider.close();
} }
/**
* Test that order is properly updated.
*/
@Test
public void testRecurrentUpdate() throws Exception {
TestableConnectionProvider provider = new TestableConnectionProvider() {
@Override
public void sendRequest(URL url, Session session) {
assertThat(url, is(locationUrl));
}
@Override
public JSON readJsonResponse() {
return getJSON("updateRecurrentOrderResponse");
}
@Override
public void handleRetryAfter(String message) {
assertThat(message, not(nullValue()));
}
};
provider.putMetadata("star-enabled", true);
Login login = provider.createLogin();
Order order = new Order(login, locationUrl);
order.update();
assertThat(order.isRecurrent(), is(true));
assertThat(order.getRecurrentStart(), is(parseTimestamp("2016-01-01T00:00:00Z")));
assertThat(order.getRecurrentEnd(), is(parseTimestamp("2017-01-01T00:00:00Z")));
assertThat(order.getRecurrentCertificateValidity(), is(Duration.ofHours(168)));
assertThat(order.getNotBefore(), is(nullValue()));
assertThat(order.getNotAfter(), is(nullValue()));
provider.close();
}
/**
* Test that recurrent order is properly canceled.
*/
@Test
public void testCancel() throws Exception {
TestableConnectionProvider provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
JSON json = claims.toJSON();
assertThat(json.get("status").asString(), is("canceled"));
assertThat(url, is(locationUrl));
assertThat(login, is(notNullValue()));
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("canceledOrderResponse");
}
};
provider.putMetadata("star-enabled", true);
Login login = provider.createLogin();
Order order = new Order(login, locationUrl);
order.cancelRecurrent();
assertThat(order.getStatus(), is(Status.CANCELED));
provider.close();
}
} }

View File

@ -25,6 +25,7 @@ import java.net.Proxy.Type;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.security.KeyPair; import java.security.KeyPair;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import org.junit.Test; import org.junit.Test;
@ -179,6 +180,9 @@ public class SessionTest {
assertThat(meta.getTermsOfService(), is(nullValue())); assertThat(meta.getTermsOfService(), is(nullValue()));
assertThat(meta.getWebsite(), is(nullValue())); assertThat(meta.getWebsite(), is(nullValue()));
assertThat(meta.getCaaIdentities(), is(empty())); assertThat(meta.getCaaIdentities(), is(empty()));
assertThat(meta.isStarEnabled(), is(false));
assertThat(meta.getStarMaxRenewal(), is(nullValue()));
assertThat(meta.getStarMinCertValidity(), is(nullValue()));
} }
/** /**
@ -208,6 +212,9 @@ public class SessionTest {
assertThat(meta.getTermsOfService(), is(URI.create("https://example.com/acme/terms"))); assertThat(meta.getTermsOfService(), is(URI.create("https://example.com/acme/terms")));
assertThat(meta.getWebsite(), is(url("https://www.example.com/"))); assertThat(meta.getWebsite(), is(url("https://www.example.com/")));
assertThat(meta.getCaaIdentities(), containsInAnyOrder("example.com")); assertThat(meta.getCaaIdentities(), containsInAnyOrder("example.com"));
assertThat(meta.isStarEnabled(), is(true));
assertThat(meta.getStarMaxRenewal(), is(Duration.ofDays(365)));
assertThat(meta.getStarMinCertValidity(), is(Duration.ofHours(24)));
assertThat(meta.isExternalAccountRequired(), is(true)); assertThat(meta.isExternalAccountRequired(), is(true));
assertThat(meta.getJSON(), is(notNullValue())); assertThat(meta.getJSON(), is(notNullValue()));
} }

View File

@ -38,6 +38,7 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP
private final Map<String, BiFunction<Login, JSON, Challenge>> creatorMap = new HashMap<>(); private final Map<String, BiFunction<Login, JSON, Challenge>> creatorMap = new HashMap<>();
private final Map<String, Challenge> createdMap = new HashMap<>(); private final Map<String, Challenge> createdMap = new HashMap<>();
private final JSONBuilder directory = new JSONBuilder(); private final JSONBuilder directory = new JSONBuilder();
private JSONBuilder metadata = null;
/** /**
* Register a {@link Resource} mapping. * Register a {@link Resource} mapping.
@ -51,6 +52,21 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP
directory.put(r.path(), u); directory.put(r.path(), u);
} }
/**
* Add a property to the metadata registry.
*
* @param key
* Metadata key
* @param value
* Metadata value
*/
public void putMetadata(String key, Object value) {
if (metadata == null) {
metadata = directory.object("meta");
}
metadata.put(key, value);
}
/** /**
* Register a {@link Challenge}. * Register a {@link Challenge}.
* *

View File

@ -18,6 +18,7 @@ import static org.junit.Assert.assertThat;
import java.io.IOException; import java.io.IOException;
import java.security.KeyPair; import java.security.KeyPair;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ -82,12 +83,14 @@ public class JSONBuilderTest {
@Test @Test
public void testDate() { public void testDate() {
Instant date = ZonedDateTime.of(2016, 6, 1, 5, 13, 46, 0, ZoneId.of("GMT+2")).toInstant(); Instant date = ZonedDateTime.of(2016, 6, 1, 5, 13, 46, 0, ZoneId.of("GMT+2")).toInstant();
Duration duration = Duration.ofMinutes(5);
JSONBuilder cb = new JSONBuilder(); JSONBuilder cb = new JSONBuilder();
cb.put("fooDate", date); cb.put("fooDate", date);
cb.put("fooNull", null); cb.put("fooDuration", duration);
cb.put("fooNull", (Object) null);
assertThat(cb.toString(), is("{\"fooDate\":\"2016-06-01T03:13:46Z\",\"fooNull\":null}")); assertThat(cb.toString(), is("{\"fooDate\":\"2016-06-01T03:13:46Z\",\"fooDuration\":300,\"fooNull\":null}"));
} }
/** /**

View File

@ -27,6 +27,7 @@ import java.io.ObjectOutputStream;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
@ -91,7 +92,7 @@ public class JSONTest {
assertThat(json.keySet(), containsInAnyOrder( assertThat(json.keySet(), containsInAnyOrder(
"text", "number", "boolean", "uri", "url", "date", "array", "text", "number", "boolean", "uri", "url", "date", "array",
"collect", "status", "binary", "problem")); "collect", "status", "binary", "duration", "problem"));
assertThat(json.contains("text"), is(true)); assertThat(json.contains("text"), is(true));
assertThat(json.contains("music"), is(false)); assertThat(json.contains("music"), is(false));
assertThat(json.get("text"), is(notNullValue())); assertThat(json.get("text"), is(notNullValue()));
@ -201,6 +202,7 @@ public class JSONTest {
assertThat(json.get("date").asInstant(), is(date)); assertThat(json.get("date").asInstant(), is(date));
assertThat(json.get("status").asStatus(), is(Status.VALID)); assertThat(json.get("status").asStatus(), is(Status.VALID));
assertThat(json.get("binary").asBinary(), is("Chainsaw".getBytes())); assertThat(json.get("binary").asBinary(), is("Chainsaw".getBytes()));
assertThat(json.get("duration").asDuration(), is(Duration.ofSeconds(86400)));
assertThat(json.get("text").isPresent(), is(true)); assertThat(json.get("text").isPresent(), is(true));
assertThat(json.get("text").optional().isPresent(), is(true)); assertThat(json.get("text").optional().isPresent(), is(true));
@ -265,6 +267,13 @@ public class JSONTest {
// expected // expected
} }
try {
json.get("none").asDuration();
fail("asDuration did not fail");
} catch (AcmeProtocolException ex) {
// expected
}
try { try {
json.get("none").asObject(); json.get("none").asObject();
fail("asObject did not fail"); fail("asObject did not fail");
@ -357,6 +366,13 @@ public class JSONTest {
// expected // expected
} }
try {
json.get("text").asDuration();
fail("no exception was thrown");
} catch (AcmeProtocolException ex) {
// expected
}
try { try {
json.get("text").asProblem(BASE_URL); json.get("text").asProblem(BASE_URL);
fail("no exception was thrown"); fail("no exception was thrown");

View File

@ -0,0 +1,3 @@
{
"status": "canceled"
}

View File

@ -9,6 +9,7 @@
"collect": ["foo", "bar", "barfoo"], "collect": ["foo", "bar", "barfoo"],
"status": "VALID", "status": "VALID",
"binary": "Q2hhaW5zYXc", "binary": "Q2hhaW5zYXc",
"duration": 86400,
"problem": { "problem": {
"type": "urn:ietf:params:acme:error:rateLimited", "type": "urn:ietf:params:acme:error:rateLimited",
"detail": "too many requests", "detail": "too many requests",

View File

@ -10,6 +10,9 @@
"example.com" "example.com"
], ],
"externalAccountRequired": true, "externalAccountRequired": true,
"star-enabled": true,
"star-min-cert-validity": 86400,
"star-max-renewal": 31536000,
"xTestString": "foobar", "xTestString": "foobar",
"xTestUri": "https://www.example.org", "xTestUri": "https://www.example.org",
"xTestArray": [ "xTestArray": [

View File

@ -0,0 +1,12 @@
{
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"recurrent": true,
"recurrent-start-date": "2018-01-01T00:00:00Z",
"recurrent-end-date": "2019-01-01T00:00:00Z",
"recurrent-certificate-validity": 604800
}

View File

@ -0,0 +1,19 @@
{
"status": "pending",
"expires": "2016-01-10T00:00:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"recurrent": true,
"recurrent-start-date": "2018-01-01T00:00:00Z",
"recurrent-end-date": "2019-01-01T00:00:00Z",
"recurrent-certificate-validity": 604800,
"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

@ -0,0 +1,7 @@
{
"status": "valid",
"recurrent": true,
"recurrent-start-date": "2016-01-01T00:00:00Z",
"recurrent-end-date": "2017-01-01T00:00:00Z",
"recurrent-certificate-validity": 604800
}

View File

@ -168,3 +168,33 @@ auth.deactivate();
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
It is not documented if the deactivation of an authorization also revokes the related certificate. If the certificate should be revoked, revoke it manually before deactivation. It is not documented if the deactivation of an authorization also revokes the related certificate. If the certificate should be revoked, revoke it manually before deactivation.
</div> </div>
## Short-Term Automatic Renewal
_acme4j_ supports the [ACME STAR](https://tools.ietf.org/html/draft-ietf-acme-star) extension for short-term automatic renewal of certificates.
To find out if the CA supports the STAR extension, check the metadata:
```java
if (session.getMetadata().isStarEnabled()) {
// CA supports STAR!
}
```
If STAR is supported, you can enable recurrent renewals by adding `recurrent()` to the order parameters:
```java
Order order = account.newOrder()
.domain("example.org")
.recurrent()
.create();
```
You can use `recurrentStart()`, `recurrentEnd()` and `recurrentCertificateValidity()` to change the time span and frequency of automatic renewals. You cannot use `notBefore()` and `notAfter()` in combination with `recurrent()` though.
The `Metadata` object also holds the accepted renewal limits (see `Metadata.getStarMinCertValidity()` and `Metadata.getStarMaxRenewal()`).
<div class="alert alert-info" role="alert">
The _ACME STAR_ support is experimental. There is currently no known ACME server implementing this extension.
</div>