diff --git a/README.md b/README.md index 85ad248e..7e72a8cd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It is an independent open source implementation that is not affiliated with or e * Supports [RFC 8738](https://tools.ietf.org/html/rfc8738) IP identifier validation * Supports [RFC 8739](https://tools.ietf.org/html/rfc8739) short-term automatic certificate renewal (experimental) * Supports [RFC 8823](https://tools.ietf.org/html/rfc8823) for S/MIME certificates (experimental) +* Supports [draft-ietf-acme-ari-01](https://www.ietf.org/id/draft-ietf-acme-ari-01.html) for renewal information * Easy to use Java API * Requires JRE 11 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/Certificate.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java index 403acb18..bdfaf658 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java @@ -19,17 +19,27 @@ import static java.util.stream.Collectors.toUnmodifiableList; import java.io.IOException; import java.io.Writer; +import java.net.MalformedURLException; import java.net.URL; import java.security.KeyPair; +import java.security.Security; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.List; +import java.util.Optional; import edu.umd.cs.findbugs.annotations.Nullable; +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeLazyLoadingException; +import org.shredzone.acme4j.exception.AcmeNotSupportedException; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.toolbox.AcmeUtils; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -48,6 +58,7 @@ public class Certificate extends AcmeResource { private @Nullable List certChain; private @Nullable Collection alternates; + private transient @Nullable RenewalInfo renewalInfo = null; protected Certificate(Login login, URL certUrl) { super(login, certUrl); @@ -136,6 +147,85 @@ public class Certificate extends AcmeResource { } } + /** + * Returns this certificate's CertID according to RFC 6960. + *

+ * This method requires the {@link org.bouncycastle.jce.provider.BouncyCastleProvider} + * security provider. + * + * @see RFC 6960 + * @since 3.0.0 + */ + public String getCertID() { + var certChain = getCertificateChain(); + if (certChain.size() < 2) { + throw new AcmeProtocolException("Certificate has no issuer"); + } + + try { + var builder = new JcaDigestCalculatorProviderBuilder(); + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) != null) { + builder.setProvider(BouncyCastleProvider.PROVIDER_NAME); + } + var digestCalc = builder.build().get(new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256)); + var issuerHolder = new X509CertificateHolder(certChain.get(1).getEncoded()); + var certId = new CertificateID(digestCalc, issuerHolder, certChain.get(0).getSerialNumber()); + return AcmeUtils.base64UrlEncode(certId.toASN1Primitive().getEncoded()); + } catch (Exception ex) { + throw new AcmeProtocolException("Could not compute Certificate ID", ex); + } + } + + /** + * Returns the location of the certificate's RenewalInfo. Empty if the CA does not + * provide this information. + * + * @since 3.0.0 + */ + public Optional getRenewalInfoLocation() { + try { + return getSession().resourceUrlOptional(Resource.RENEWAL_INFO) + .map(baseUrl -> { + try { + var url = baseUrl.toExternalForm(); + if (!url.endsWith("/")) { + url += '/'; + } + url += getCertID(); + return new URL(url); + } catch (MalformedURLException ex) { + throw new AcmeProtocolException("Invalid RenewalInfo URL", ex); + } + }); + } catch (AcmeException ex) { + throw new AcmeLazyLoadingException(this, ex); + } + } + + /** + * Returns {@code true} if the CA provides renewal information. + * + * @since 3.0.0 + */ + public boolean hasRenewalInfo() { + return getRenewalInfoLocation().isPresent(); + } + + /** + * Reads the RenewalInfo for this certificate. + * + * @return The {@link RenewalInfo} of this certificate. + * @since 3.0.0 + */ + public RenewalInfo getRenewalInfo() { + if (renewalInfo == null) { + renewalInfo = getRenewalInfoLocation() + .map(getLogin()::bindRenewalInfo) + .orElseThrow(() -> new AcmeNotSupportedException("renewal-info")); + } + return renewalInfo; + } + /** * Revokes this certificate. */ diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Login.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Login.java index fe79768a..142e5e52 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Login.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Login.java @@ -132,6 +132,19 @@ public class Login { return new Order(this, requireNonNull(location, "location")); } + /** + * Creates a new instance of an existing {@link RenewalInfo} and binds it to this + * login. + * + * @param location + * Location URL of the renewal info + * @return {@link RenewalInfo} bound to the login + * @since 3.0.0 + */ + public RenewalInfo bindRenewalInfo(URL location) { + return new RenewalInfo(this, requireNonNull(location, "location")); + } + /** * Creates a new instance of an existing {@link Challenge} and binds it to this * login. Use this method only if the resulting challenge type is unknown. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java b/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java new file mode 100644 index 00000000..35a768b7 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java @@ -0,0 +1,194 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2023 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.net.URL; +import java.time.Instant; +import java.time.temporal.TemporalAmount; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +import edu.umd.cs.findbugs.annotations.Nullable; +import org.shredzone.acme4j.connector.Connection; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.toolbox.JSON.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Renewal Information of a certificate. + * + * @since 3.0.0 + */ +public class RenewalInfo extends AcmeJsonResource { + private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class); + + private @Nullable Instant recheckAfter = null; + + protected RenewalInfo(Login login, URL location) { + super(login, location); + } + + /** + * Returns the starting {@link Instant} of the time window the CA recommends for + * certificate renewal. + */ + public Instant getSuggestedWindowStart() { + return getJSON().get("suggestedWindow").asObject().get("start").asInstant(); + } + + /** + * Returns the ending {@link Instant} of the time window the CA recommends for + * certificate renewal. + */ + public Instant getSuggestedWindowEnd() { + return getJSON().get("suggestedWindow").asObject().get("end").asInstant(); + } + + /** + * An optional {@link URL} pointing to a page which may explain why the suggested + * renewal window is what it is. + */ + public Optional getExplanation() { + return getJSON().get("explanationURL").optional().map(Value::asURL); + } + + /** + * An optional {@link Instant} that serves as recommendation when to re-check the + * renewal information of a certificate. + */ + public Optional getRecheckAfter() { + getJSON(); // make sure resource is loaded + return Optional.ofNullable(recheckAfter); + } + + /** + * Checks if the given {@link Instant} is before the suggested time window, so a + * certificate renewal is not required yet. + * + * @param instant + * {@link Instant} to check + * @return {@code true} if the {@link Instant} is before the time window, {@code + * false} otherwise. + */ + public boolean renewalIsNotRequired(Instant instant) { + assertValidTimeWindow(); + return instant.isBefore(getSuggestedWindowStart()); + } + + /** + * Checks if the given {@link Instant} is within the suggested time window, and a + * certificate renewal is recommended. + *

+ * An {@link Instant} is deemed to be within the time window if it is equal to, or + * after {@link #getSuggestedWindowStart()}, and before {@link + * #getSuggestedWindowEnd()}. + * + * @param instant + * {@link Instant} to check + * @return {@code true} if the {@link Instant} is within the time window, {@code + * false} otherwise. + */ + public boolean renewalIsRecommended(Instant instant) { + assertValidTimeWindow(); + return !instant.isBefore(getSuggestedWindowStart()) + && instant.isBefore(getSuggestedWindowEnd()); + } + + /** + * Checks if the given {@link Instant} is past the time window, and a certificate + * renewal is overdue. + *

+ * An {@link Instant} is deemed to be past the time window if it is equal to, or after + * {@link #getSuggestedWindowEnd()}. + * + * @param instant + * {@link Instant} to check + * @return {@code true} if the {@link Instant} is past the time window, {@code false} + * otherwise. + */ + public boolean renewalIsOverdue(Instant instant) { + assertValidTimeWindow(); + return !instant.isBefore(getSuggestedWindowEnd()); + } + + /** + * Returns a proposed {@link Instant} when the certificate related to this + * {@link RenewalInfo} should be renewed. + *

+ * This method is useful for setting alarms for renewal cron jobs. As a parameter, the + * frequency of the cron job is set. The resulting {@link Instant} is guaranteed to be + * executed in time, considering the cron job intervals. + *

+ * This method uses {@link ThreadLocalRandom} for random numbers. It is sufficient for + * most cases, as only an "earliest" {@link Instant} is returned, but the actual + * renewal process also depends on cron job execution times and other factors like + * system load. + *

+ * The result is empty if it is impossible to renew the certificate in time, under the + * given circumstances. This is either because the time window already ended in the + * past, or because the cron job would not be executed before the ending of the time + * window. In this case, it is recommended to renew the certificate immediately. + * + * @param frequency + * Frequency of the cron job executing the certificate renewals. May be + * {@code null} if there is no cron job, and the renewal is going to be + * executed exactly at the given {@link Instant}. + * @return Random {@link Instant} when the certificate should be renewed. This instant + * might be slightly in the past. In this case, start the renewal process at the next + * possible regular moment. + */ + public Optional getRandomProposal(@Nullable TemporalAmount frequency) { + assertValidTimeWindow(); + Instant start = Instant.now(); + Instant suggestedStart = getSuggestedWindowStart(); + if (start.isBefore(suggestedStart)) { + start = suggestedStart; + } + + Instant end = getSuggestedWindowEnd(); + if (frequency != null) { + end = end.minus(frequency); + } + + if (!end.isAfter(start)) { + return Optional.empty(); + } + + return Optional.of(Instant.ofEpochMilli(ThreadLocalRandom.current().nextLong( + start.toEpochMilli(), + end.toEpochMilli()))); + } + + @Override + public void update() throws AcmeException { + LOG.debug("update RenewalInfo"); + try (Connection conn = getSession().connect()) { + conn.sendRequest(getLocation(), getSession(), null); + setJSON(conn.readJsonResponse()); + recheckAfter = conn.getRetryAfter().orElse(null); + } + } + + /** + * Asserts that the end of the suggested time window is after the start. + */ + private void assertValidTimeWindow() { + if (getSuggestedWindowStart().isAfter(getSuggestedWindowEnd())) { + throw new AcmeProtocolException("Received an invalid suggested window"); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java index 4663965a..af5e4914 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java @@ -23,7 +23,8 @@ public enum Resource { NEW_ORDER("newOrder"), NEW_AUTHZ("newAuthz"), REVOKE_CERT("revokeCert"), - KEY_CHANGE("keyChange"); + KEY_CHANGE("keyChange"), + RENEWAL_INFO("renewalInfo"); private final String path; diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java b/acme4j-client/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java index 57e02bb7..29d7ac3e 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java @@ -25,6 +25,7 @@ import java.security.PublicKey; import java.security.SecureRandom; import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.PEMException; import org.bouncycastle.openssl.PEMKeyPair; import org.bouncycastle.openssl.PEMParser; @@ -82,7 +83,7 @@ public class KeyPairUtils { public static KeyPair createECKeyPair(String name) { try { var ecSpec = ECNamedCurveTable.getParameterSpec(name); - var g = KeyPairGenerator.getInstance("ECDSA", "BC"); + var g = KeyPairGenerator.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME); g.initialize(ecSpec, new SecureRandom()); return g.generateKeyPair(); } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException ex) { diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java index 551db8db..b22affd6 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java @@ -25,15 +25,20 @@ import java.net.HttpURLConnection; import java.net.URL; import java.security.KeyPair; import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.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; import org.shredzone.acme4j.toolbox.TestUtils; @@ -50,7 +55,7 @@ public class CertificateTest { */ @Test public void testDownload() throws Exception { - var originalCert = TestUtils.createCertificate(); + var originalCert = TestUtils.createCertificate("/cert.pem"); var provider = new TestableConnectionProvider() { @Override @@ -127,7 +132,7 @@ public class CertificateTest { */ @Test public void testRevokeCertificate() throws AcmeException, IOException { - var originalCert = TestUtils.createCertificate(); + var originalCert = TestUtils.createCertificate("/cert.pem"); var provider = new TestableConnectionProvider() { private boolean certRequested = false; @@ -175,7 +180,7 @@ public class CertificateTest { */ @Test public void testRevokeCertificateWithReason() throws AcmeException, IOException { - var originalCert = TestUtils.createCertificate(); + var originalCert = TestUtils.createCertificate("/cert.pem"); var provider = new TestableConnectionProvider() { private boolean certRequested = false; @@ -232,7 +237,7 @@ public class CertificateTest { */ @Test public void testRevokeCertificateByKeyPair() throws AcmeException, IOException { - var originalCert = TestUtils.createCertificate(); + var originalCert = TestUtils.createCertificate("/cert.pem"); var certKeyPair = TestUtils.createDomainKeyPair(); var provider = new TestableConnectionProvider() { @@ -255,4 +260,83 @@ public class CertificateTest { provider.close(); } + /** + * Test that RenewalInfo is returned. + */ + @Test + public void testRenewalInfo() throws AcmeException, IOException { + var certId = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c"; + // certid-cert.pem and certId provided by draft-ietf-acme-ari-01 and known good + var certIdCert = TestUtils.createCertificate("/certid-cert.pem"); + var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId); + var retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS); + + var provider = new TestableConnectionProvider() { + private boolean certRequested = false; + private boolean infoRequested = false; + + @Override + public int sendCertificateRequest(URL url, Login login) { + assertThat(url).isEqualTo(locationUrl); + assertThat(login).isNotNull(); + certRequested = true; + return HttpURLConnection.HTTP_OK; + } + + @Override + public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) { + assertThat(url).isEqualTo(certResourceUrl); + assertThat(session).isNotNull(); + assertThat(ifModifiedSince).isNull(); + infoRequested = true; + return HttpURLConnection.HTTP_OK; + } + + @Override + public JSON readJsonResponse() { + assertThat(infoRequested).isTrue(); + return getJSON("renewalInfo"); + } + + @Override + public List readCertificates() { + assertThat(certRequested).isTrue(); + return certIdCert; + } + + @Override + public Collection getLinks(String relation) { + return Collections.emptyList(); + } + + @Override + public Optional getRetryAfter() { + return Optional.of(retryAfterInstant); + } + }; + + provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl); + + var cert = new Certificate(provider.createLogin(), locationUrl); + assertThat(cert.getCertID()).isEqualTo(certId); + assertThat(cert.hasRenewalInfo()).isTrue(); + assertThat(cert.getRenewalInfoLocation()) + .isNotEmpty() + .contains(certResourceUrl); + + var renewalInfo = cert.getRenewalInfo(); + assertThat(renewalInfo.getRecheckAfter()) + .isNotEmpty() + .contains(retryAfterInstant); + assertThat(renewalInfo.getSuggestedWindowStart()) + .isEqualTo("2021-01-03T00:00:00Z"); + assertThat(renewalInfo.getSuggestedWindowEnd()) + .isEqualTo("2021-01-07T00:00:00Z"); + assertThat(renewalInfo.getExplanation()) + .isNotEmpty() + .contains(url("https://example.com/docs/example-mass-reissuance-event")); + + provider.close(); + } + } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java new file mode 100644 index 00000000..d64adfd8 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java @@ -0,0 +1,183 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2023 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; +import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; +import static org.shredzone.acme4j.toolbox.TestUtils.url; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +import org.assertj.core.api.AutoCloseableSoftAssertions; +import org.junit.jupiter.api.Test; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.provider.TestableConnectionProvider; +import org.shredzone.acme4j.toolbox.JSON; + +/** + * Unit test for {@link RenewalInfo}. + */ +public class RenewalInfoTest { + + private final URL locationUrl = url("http://example.com/acme/renewalInfo/1234"); + private final Instant retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS); + private final Instant startWindow = Instant.parse("2021-01-03T00:00:00Z"); + private final Instant endWindow = Instant.parse("2021-01-07T00:00:00Z"); + + @Test + public void testGetters() throws Exception { + var provider = new TestableConnectionProvider() { + @Override + public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) { + assertThat(url).isEqualTo(locationUrl); + assertThat(session).isNotNull(); + assertThat(ifModifiedSince).isNull(); + return HttpURLConnection.HTTP_OK; + } + + @Override + public JSON readJsonResponse() { + return getJSON("renewalInfo"); + } + + @Override + public Optional getRetryAfter() { + return Optional.of(retryAfterInstant); + } + }; + + var login = provider.createLogin(); + + var renewalInfo = new RenewalInfo(login, locationUrl); + renewalInfo.update(); + + // Check getters + try (var softly = new AutoCloseableSoftAssertions()) { + softly.assertThat(renewalInfo.getLocation()).isEqualTo(locationUrl); + softly.assertThat(renewalInfo.getRecheckAfter()) + .isNotEmpty() + .contains(retryAfterInstant); + softly.assertThat(renewalInfo.getSuggestedWindowStart()) + .isEqualTo(startWindow); + softly.assertThat(renewalInfo.getSuggestedWindowEnd()) + .isEqualTo(endWindow); + softly.assertThat(renewalInfo.getExplanation()) + .isNotEmpty() + .contains(url("https://example.com/docs/example-mass-reissuance-event")); + } + + // Check renewalIsNotRequired + try (var softly = new AutoCloseableSoftAssertions()) { + softly.assertThat(renewalInfo.renewalIsNotRequired(startWindow.minusSeconds(1L))) + .isTrue(); + softly.assertThat(renewalInfo.renewalIsNotRequired(startWindow)) + .isFalse(); + softly.assertThat(renewalInfo.renewalIsNotRequired(endWindow.minusSeconds(1L))) + .isFalse(); + softly.assertThat(renewalInfo.renewalIsNotRequired(endWindow)) + .isFalse(); + } + + // Check renewalIsRecommended + try (var softly = new AutoCloseableSoftAssertions()) { + softly.assertThat(renewalInfo.renewalIsRecommended(startWindow.minusSeconds(1L))) + .isFalse(); + softly.assertThat(renewalInfo.renewalIsRecommended(startWindow)) + .isTrue(); + softly.assertThat(renewalInfo.renewalIsRecommended(endWindow.minusSeconds(1L))) + .isTrue(); + softly.assertThat(renewalInfo.renewalIsRecommended(endWindow)) + .isFalse(); + } + + // Check renewalIsOverdue + try (var softly = new AutoCloseableSoftAssertions()) { + softly.assertThat(renewalInfo.renewalIsOverdue(startWindow.minusSeconds(1L))) + .isFalse(); + softly.assertThat(renewalInfo.renewalIsOverdue(startWindow)) + .isFalse(); + softly.assertThat(renewalInfo.renewalIsOverdue(endWindow.minusSeconds(1L))) + .isFalse(); + softly.assertThat(renewalInfo.renewalIsOverdue(endWindow)) + .isTrue(); + } + + // Check getRandomProposal, is empty because end window is in the past + var proposal = renewalInfo.getRandomProposal(null); + assertThat(proposal).isEmpty(); + + provider.close(); + } + + @Test + public void testRandomProposal() { + var login = mock(Login.class); + var start = Instant.now(); + var end = start.plus(1L, ChronoUnit.DAYS); + + var renewalInfo = new RenewalInfo(login, locationUrl) { + @Override + public Instant getSuggestedWindowStart() { + return start; + } + + @Override + public Instant getSuggestedWindowEnd() { + return end; + } + }; + + var noFreq = renewalInfo.getRandomProposal(null); + assertThat(noFreq).isNotEmpty(); + assertThat(noFreq.get()).isBetween(start, end); + + var oneHour = renewalInfo.getRandomProposal(Duration.ofHours(1L)); + assertThat(oneHour).isNotEmpty(); + assertThat(oneHour.get()).isBetween(start, end.minus(1L, ChronoUnit.HOURS)); + + var twoDays = renewalInfo.getRandomProposal(Duration.ofDays(2L)); + assertThat(twoDays).isEmpty(); + } + + @Test + public void testDateAssertion() { + var login = mock(Login.class); + var start = Instant.now(); + var end = start.minusSeconds(1L); // end before start + + var renewalInfo = new RenewalInfo(login, locationUrl) { + @Override + public Instant getSuggestedWindowStart() { + return start; + } + + @Override + public Instant getSuggestedWindowEnd() { + return end; + } + }; + + assertThatExceptionOfType(AcmeProtocolException.class) + .isThrownBy(() -> renewalInfo.renewalIsRecommended(start)); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java index 79c11c70..8ea4f69b 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java @@ -855,7 +855,7 @@ public class DefaultConnectionTest { downloaded = conn.readCertificates(); } - var original = TestUtils.createCertificate(); + var original = TestUtils.createCertificate("/cert.pem"); assertThat(original).hasSize(2); assertThat(downloaded).isNotNull(); @@ -873,7 +873,7 @@ public class DefaultConnectionTest { // Build a broken certificate chain PEM file byte[] brokenPem; try (var baos = new ByteArrayOutputStream(); var w = new OutputStreamWriter(baos)) { - for (var cert : TestUtils.createCertificate()) { + for (var cert : TestUtils.createCertificate("/cert.pem")) { var badCert = cert.getEncoded(); Arrays.sort(badCert); // break it AcmeUtils.writeToPem(badCert, AcmeUtils.PemLabel.CERTIFICATE, w); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java index 8582b5a1..513ce83b 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java @@ -35,10 +35,11 @@ public class ResourceTest { softly.assertThat(Resource.NEW_AUTHZ.path()).isEqualTo("newAuthz"); softly.assertThat(Resource.REVOKE_CERT.path()).isEqualTo("revokeCert"); softly.assertThat(Resource.KEY_CHANGE.path()).isEqualTo("keyChange"); + softly.assertThat(Resource.RENEWAL_INFO.path()).isEqualTo("renewalInfo"); }); // fails if there are untested future Resource values - assertThat(Resource.values()).hasSize(6); + assertThat(Resource.values()).hasSize(7); } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java index 12d4c0c7..e7de4352 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java @@ -237,7 +237,7 @@ public class AcmeUtilsTest { */ @Test public void testWriteToPem() throws IOException, CertificateEncodingException { - var certChain = TestUtils.createCertificate(); + var certChain = TestUtils.createCertificate("/cert.pem"); var pemFile = new ByteArrayOutputStream(); try (var w = new OutputStreamWriter(pemFile)) { diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/TestUtils.java b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/TestUtils.java index 6c147e97..4e612fdb 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/TestUtils.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/TestUtils.java @@ -259,10 +259,12 @@ public final class TestUtils { * Creates a standard certificate chain for testing. This certificate is read from a * test resource and is guaranteed not to change between test runs. * + * @param resource + * Name of the resource * @return List of {@link X509Certificate} for testing */ - public static List createCertificate() throws IOException { - try (var in = TestUtils.class.getResourceAsStream("/cert.pem")) { + public static List createCertificate(String resource) throws IOException { + try (var in = TestUtils.class.getResourceAsStream(resource)) { var cf = CertificateFactory.getInstance("X.509"); return cf.generateCertificates(in).stream() .map(c -> (X509Certificate) c) diff --git a/acme4j-client/src/test/resources/certid-cert.pem b/acme4j-client/src/test/resources/certid-cert.pem new file mode 100644 index 00000000..89d7020d --- /dev/null +++ b/acme4j-client/src/test/resources/certid-cert.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDMDCCAhigAwIBAgIIPqNFaGVEHxwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE +AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MB4XDTIyMDMxNzE3NTEwOVoXDTI0MDQx +NjE3NTEwOVowFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCgm9K/c+il2Pf0f8qhgxn9SKqXq88cOm9ov9AVRbPA +OWAAewqX2yUAwI4LZBGEgzGzTATkiXfoJ3cN3k39cH6tBbb3iSPuEn7OZpIk9D+e +3Q9/hX+N/jlWkaTB/FNA+7aE5IVWhmdczYilXa10V9r+RcvACJt0gsipBZVJ4jfJ +HnWJJGRZzzxqG/xkQmpXxZO7nOPFc8SxYKWdfcgp+rjR2ogYhSz7BfKoVakGPbpX +vZOuT9z4kkHra/WjwlkQhtHoTXdAxH3qC2UjMzO57Tx+otj0CxAv9O7CTJXISywB +vEVcmTSZkHS3eZtvvIwPx7I30ITRkYk/tLl1MbyB3SiZAgMBAAGjeDB2MA4GA1Ud +DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T +AQH/BAIwADAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTAWBgNVHREE +DzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAx0aYvmCk7JYGNEXe ++hrOfKawkHYzWvA92cI/Oi6h+oSdHZ2UKzwFNf37cVKZ37FCrrv5pFP/xhhHvrNV +EnOx4IaF7OrnaTu5miZiUWuvRQP7ZGmGNFYbLTEF6/dj+WqyYdVaWzxRqHFu1ptC +TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp +1URpvffLZQ8xPsEgOZyPWOcabTwJrtqBwily+lwPFn2mChUx846LwQfxtsXU/lJg +HX2RteNJx7YYNeX3Uf960mgo5an6vE8QNAsIoNHYrGyEmXDhTRe9mCHyiW2S7fZq +o9q12g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIIOhNWtJ7Igr0wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE +AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MCAXDTIyMDMxNzE3NTEwOVoYDzIxMjIw +MzE3MTc1MTA5WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAzYTEzNTYwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDc3P6cxcCZ7FQOQrYuigReSa8T +IOPNKmlmX9OrTkPwjThiMNEETYKO1ea99yXPK36LUHC6OLmZ9jVQW2Ny1qwQCOy6 +TrquhnwKgtkBMDAZBLySSEXYdKL3r0jA4sflW130/OLwhstU/yv0J8+pj7eSVOR3 +zJBnYd1AqnXHRSwQm299KXgqema7uwsa8cgjrXsBzAhrwrvYlVhpWFSv3lQRDFQg +c5Z/ZDV9i26qiaJsCCmdisJZWN7N2luUgxdRqzZ4Cr2Xoilg3T+hkb2y/d6ttsPA +kaSA+pq3q6Qa7/qfGdT5WuUkcHpvKNRWqnwT9rCYlmG00r3hGgc42D/z1VvfAgMB +AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr +BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ4zzDRUaXHVKql +STWkULGU4zGZpTAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTANBgkq +hkiG9w0BAQsFAAOCAQEArbDHhEjGedjb/YjU80aFTPWOMRjgyfQaPPgyxwX6Dsid +1i2H1x4ud4ntz3sTZZxdQIrOqtlIWTWVCjpStwGxaC+38SdreiTTwy/nikXGa/6W +ZyQRppR3agh/pl5LHVO6GsJz3YHa7wQhEhj3xsRwa9VrRXgHbLGbPOFVRTHPjaPg +Gtsv2PN3f67DsPHF47ASqyOIRpLZPQmZIw6D3isJwfl+8CzvlB1veO0Q3uh08IJc +fspYQXvFBzYa64uKxNAJMi4Pby8cf4r36Wnb7cL4ho3fOHgAltxdW8jgibRzqZpQ +QKyxn2jX7kxeUDt0hFDJE8lOrhP73m66eBNzxe//FQ== +-----END CERTIFICATE----- diff --git a/acme4j-client/src/test/resources/json/directory.json b/acme4j-client/src/test/resources/json/directory.json index f2f4cc1a..ae6e189d 100644 --- a/acme4j-client/src/test/resources/json/directory.json +++ b/acme4j-client/src/test/resources/json/directory.json @@ -3,6 +3,7 @@ "newAccount": "https://example.com/acme/new-account", "newOrder": "https://example.com/acme/new-order", "newAuthz": "https://example.com/acme/new-authz", + "renewalInfo": "https://example.com/acme/renewal-info", "meta": { "termsOfService": "https://example.com/acme/terms", "website": "https://www.example.com/", diff --git a/acme4j-client/src/test/resources/json/renewalInfo.json b/acme4j-client/src/test/resources/json/renewalInfo.json new file mode 100644 index 00000000..6e262e16 --- /dev/null +++ b/acme4j-client/src/test/resources/json/renewalInfo.json @@ -0,0 +1,7 @@ +{ + "suggestedWindow": { + "start": "2021-01-03T00:00:00Z", + "end": "2021-01-07T00:00:00Z" + }, + "explanationURL": "https://example.com/docs/example-mass-reissuance-event" +} \ No newline at end of file diff --git a/src/doc/docs/index.md b/src/doc/docs/index.md index fdf4e2e5..dc10b94c 100644 --- a/src/doc/docs/index.md +++ b/src/doc/docs/index.md @@ -20,6 +20,7 @@ Latest version: ![maven central](https://shredzone.org/maven-central/org.shredzo * Supports [RFC 8738](https://tools.ietf.org/html/rfc8738) IP identifier validation * Supports [RFC 8739](https://tools.ietf.org/html/rfc8739) short-term automatic certificate renewal (experimental) * Supports [RFC 8823](https://tools.ietf.org/html/rfc8823) for S/MIME certificates (experimental) +* Supports [draft-ietf-acme-ari-01](https://www.ietf.org/id/draft-ietf-acme-ari-01.html) for renewal information * Easy to use Java API * Requires JRE 11 or higher * Built with maven, packages available at [Maven Central](http://search.maven.org/#search|ga|1|g%3A%22org.shredzone.acme4j%22)