diff --git a/README.md b/README.md index f9769a75..38a50de1 100644 --- a/README.md +++ b/README.md @@ -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) * 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-star draft](https://tools.ietf.org/html/draft-ietf-acme-star) for short-term automatic certificate renewal (experimental) * Easy to use Java API * 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) 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 bb62caa6..7ef78b77 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java @@ -17,6 +17,7 @@ import static java.util.stream.Collectors.toList; import java.net.URI; import java.net.URL; +import java.time.Duration; import java.util.Collection; import javax.annotation.CheckForNull; @@ -82,6 +83,36 @@ public class Metadata { 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 * proprietary metadata properties. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java index 35452e1d..bc75e07f 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java @@ -16,6 +16,7 @@ package org.shredzone.acme4j; import static java.util.stream.Collectors.toList; import java.net.URL; +import java.time.Duration; import java.time.Instant; import java.util.Collections; import java.util.List; @@ -25,6 +26,7 @@ import javax.annotation.ParametersAreNonnullByDefault; import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON.Value; import org.shredzone.acme4j.toolbox.JSONBuilder; import org.slf4j.Logger; @@ -169,4 +171,81 @@ public class Order extends AcmeJsonResource { 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); + } + } + } + } 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 8bd40f61..50e41317 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java @@ -17,6 +17,7 @@ import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toList; import java.net.URL; +import java.time.Duration; import java.time.Instant; import java.util.Collection; import java.util.LinkedHashSet; @@ -45,6 +46,10 @@ public class OrderBuilder { private final Set identifierSet = new LinkedHashSet<>(); private Instant notBefore; private Instant notAfter; + private boolean recurrent; + private Instant recurrentStart; + private Instant recurrentEnd; + private Duration recurrentValidity; /** * Create a new {@link OrderBuilder}. @@ -131,6 +136,9 @@ public class OrderBuilder { * @return itself */ public OrderBuilder notBefore(Instant notBefore) { + if (recurrent) { + throw new IllegalArgumentException("cannot combine notBefore with recurrent"); + } this.notBefore = requireNonNull(notBefore, "notBefore"); return this; } @@ -142,10 +150,84 @@ public class OrderBuilder { * @return itself */ public OrderBuilder notAfter(Instant notAfter) { + if (recurrent) { + throw new IllegalArgumentException("cannot combine notAfter with recurrent"); + } this.notAfter = requireNonNull(notAfter, "notAfter"); return this; } + /** + * Enables short-term automatic renewal of the certificate. Must be supported by the + * CA. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. * @@ -158,6 +240,10 @@ public class OrderBuilder { Session session = login.getSession(); + if (recurrent && !session.getMetadata().isStarEnabled()) { + throw new AcmeException("CA does not support short-term automatic renewals"); + } + LOG.debug("create"); try (Connection conn = session.provider().connect()) { JSONBuilder claims = new JSONBuilder(); @@ -170,6 +256,19 @@ public class OrderBuilder { 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); URL orderLocation = conn.getLocation(); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Status.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Status.java index 70e43404..999c2aea 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Status.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Status.java @@ -67,6 +67,13 @@ public enum Status { */ 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 * status. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSON.java b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSON.java index c17e4fb6..2db9e905 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSON.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSON.java @@ -27,6 +27,7 @@ import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.time.Duration; import java.time.Instant; import java.util.Collections; 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. */ diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java index 0c323300..21020524 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java @@ -17,6 +17,7 @@ import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode; import java.security.Key; import java.security.PublicKey; +import java.time.Duration; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Collection; @@ -84,6 +85,27 @@ public class JSONBuilder { 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. * 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 cab33d25..e05bb2dd 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java @@ -14,7 +14,7 @@ package org.shredzone.acme4j; 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.TestUtils.*; 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.InetAddress; import java.net.URL; +import java.time.Duration; import java.time.Instant; import java.util.Arrays; import org.junit.Test; import org.shredzone.acme4j.connector.Resource; +import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -112,4 +114,141 @@ public class OrderBuilderTest { 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(); + } + } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java index 88b5a0a8..9272807f 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java @@ -22,6 +22,7 @@ import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; +import java.time.Duration; import java.util.List; 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.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().getType(), is(URI.create("urn:ietf:params:acme:error:connection"))); assertThat(order.getError().getDetail(), is("connection refused")); @@ -202,4 +208,76 @@ public class OrderTest { 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(); + } + } 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 d0df1e16..9d17e5cc 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java @@ -25,6 +25,7 @@ import java.net.Proxy.Type; import java.net.URI; import java.net.URL; import java.security.KeyPair; +import java.time.Duration; import java.time.Instant; import org.junit.Test; @@ -179,6 +180,9 @@ public class SessionTest { assertThat(meta.getTermsOfService(), is(nullValue())); assertThat(meta.getWebsite(), is(nullValue())); 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.getWebsite(), is(url("https://www.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.getJSON(), is(notNullValue())); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java index e8239091..ad437f84 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java @@ -38,6 +38,7 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP private final Map> creatorMap = new HashMap<>(); private final Map createdMap = new HashMap<>(); private final JSONBuilder directory = new JSONBuilder(); + private JSONBuilder metadata = null; /** * Register a {@link Resource} mapping. @@ -51,6 +52,21 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP 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}. * diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONBuilderTest.java index 2a35ceeb..b70c6b09 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONBuilderTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertThat; import java.io.IOException; import java.security.KeyPair; +import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -82,12 +83,14 @@ public class JSONBuilderTest { @Test public void testDate() { 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(); 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}")); } /** diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONTest.java index 49473c18..05b183bd 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONTest.java @@ -27,6 +27,7 @@ import java.io.ObjectOutputStream; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -91,7 +92,7 @@ public class JSONTest { assertThat(json.keySet(), containsInAnyOrder( "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("music"), is(false)); assertThat(json.get("text"), is(notNullValue())); @@ -201,6 +202,7 @@ public class JSONTest { assertThat(json.get("date").asInstant(), is(date)); assertThat(json.get("status").asStatus(), is(Status.VALID)); 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").optional().isPresent(), is(true)); @@ -265,6 +267,13 @@ public class JSONTest { // expected } + try { + json.get("none").asDuration(); + fail("asDuration did not fail"); + } catch (AcmeProtocolException ex) { + // expected + } + try { json.get("none").asObject(); fail("asObject did not fail"); @@ -357,6 +366,13 @@ public class JSONTest { // expected } + try { + json.get("text").asDuration(); + fail("no exception was thrown"); + } catch (AcmeProtocolException ex) { + // expected + } + try { json.get("text").asProblem(BASE_URL); fail("no exception was thrown"); diff --git a/acme4j-client/src/test/resources/json/canceledOrderResponse.json b/acme4j-client/src/test/resources/json/canceledOrderResponse.json new file mode 100644 index 00000000..9e3a84b7 --- /dev/null +++ b/acme4j-client/src/test/resources/json/canceledOrderResponse.json @@ -0,0 +1,3 @@ +{ + "status": "canceled" +} diff --git a/acme4j-client/src/test/resources/json/datatypes.json b/acme4j-client/src/test/resources/json/datatypes.json index 90997f90..695895ef 100644 --- a/acme4j-client/src/test/resources/json/datatypes.json +++ b/acme4j-client/src/test/resources/json/datatypes.json @@ -9,6 +9,7 @@ "collect": ["foo", "bar", "barfoo"], "status": "VALID", "binary": "Q2hhaW5zYXc", + "duration": 86400, "problem": { "type": "urn:ietf:params:acme:error:rateLimited", "detail": "too many requests", diff --git a/acme4j-client/src/test/resources/json/directory.json b/acme4j-client/src/test/resources/json/directory.json index 6b3e97b4..850158c6 100644 --- a/acme4j-client/src/test/resources/json/directory.json +++ b/acme4j-client/src/test/resources/json/directory.json @@ -10,6 +10,9 @@ "example.com" ], "externalAccountRequired": true, + "star-enabled": true, + "star-min-cert-validity": 86400, + "star-max-renewal": 31536000, "xTestString": "foobar", "xTestUri": "https://www.example.org", "xTestArray": [ diff --git a/acme4j-client/src/test/resources/json/requestRecurrentOrderRequest.json b/acme4j-client/src/test/resources/json/requestRecurrentOrderRequest.json new file mode 100644 index 00000000..53b99ec0 --- /dev/null +++ b/acme4j-client/src/test/resources/json/requestRecurrentOrderRequest.json @@ -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 +} diff --git a/acme4j-client/src/test/resources/json/requestRecurrentOrderResponse.json b/acme4j-client/src/test/resources/json/requestRecurrentOrderResponse.json new file mode 100644 index 00000000..bf1d33ba --- /dev/null +++ b/acme4j-client/src/test/resources/json/requestRecurrentOrderResponse.json @@ -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" +} diff --git a/acme4j-client/src/test/resources/json/updateRecurrentOrderResponse.json b/acme4j-client/src/test/resources/json/updateRecurrentOrderResponse.json new file mode 100644 index 00000000..ebcfd9eb --- /dev/null +++ b/acme4j-client/src/test/resources/json/updateRecurrentOrderResponse.json @@ -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 +} diff --git a/src/site/markdown/usage/order.md b/src/site/markdown/usage/order.md index ac0c087e..501fce09 100644 --- a/src/site/markdown/usage/order.md +++ b/src/site/markdown/usage/order.md @@ -168,3 +168,33 @@ auth.deactivate();

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