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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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, Challenge> 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}.
*

View File

@ -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}"));
}
/**

View File

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

View File

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

View File

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

View File

@ -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": [

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">
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>
## 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>