mirror of https://github.com/shred/acme4j
Add support for draft-ietf-acme-ari-01
parent
727cd94028
commit
d9894f42eb
|
@ -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)
|
||||
|
|
|
@ -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<X509Certificate> certChain;
|
||||
private @Nullable Collection<URL> 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.
|
||||
* <p>
|
||||
* This method requires the {@link org.bouncycastle.jce.provider.BouncyCastleProvider}
|
||||
* security provider.
|
||||
*
|
||||
* @see <a href="https://www.rfc-editor.org/rfc/rfc6960.html">RFC 6960</a>
|
||||
* @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<URL> 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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<URL> 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<Instant> 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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<Instant> 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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<X509Certificate> readCertificates() {
|
||||
assertThat(certRequested).isTrue();
|
||||
return certIdCert;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<URL> getLinks(String relation) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Instant> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<Instant> 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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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<X509Certificate> createCertificate() throws IOException {
|
||||
try (var in = TestUtils.class.getResourceAsStream("/cert.pem")) {
|
||||
public static List<X509Certificate> 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)
|
||||
|
|
|
@ -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-----
|
|
@ -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/",
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -20,6 +20,7 @@ Latest version:  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)
|
||||
|
|
Loading…
Reference in New Issue