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 827f1c8f..6408db0f 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java @@ -268,6 +268,28 @@ public class Certificate extends AcmeResource { return renewalInfo; } + /** + * Signals to the CA that this certificate has been successfully replaced by a newer + * one. A revocation of this certificate would not disrupt any ongoing services. + *

+ * This method is only supported by CAs that are providing renewal information + * (see {@link #hasRenewalInfo()}. An {@link AcmeNotSupportedException} is thrown + * otherwise. + * + * @since 3.1.0 + */ + public void markAsReplaced() throws AcmeException { + LOG.debug("mark as replaced"); + var session = getSession(); + var renewalInfoUrl = session.resourceUrl(Resource.RENEWAL_INFO); + try (var conn = session.connect()) { + var claims = new JSONBuilder(); + claims.put("certID", getCertID()); + claims.put("replaced", true); + conn.sendSignedRequest(renewalInfoUrl, claims, getLogin()); + } + } + /** * Revokes this certificate. */ 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 02ff0c80..062f89dc 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java @@ -15,6 +15,7 @@ package org.shredzone.acme4j; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.shredzone.acme4j.toolbox.TestUtils.*; import java.io.ByteArrayOutputStream; @@ -36,6 +37,7 @@ 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.exception.AcmeNotSupportedException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -358,4 +360,101 @@ public class CertificateTest { provider.close(); } + /** + * Test that a certificate is marked as replaced. + */ + @Test + public void testMarkedAsReplaced() throws AcmeException, IOException { + // certid-cert.pem and certId provided by draft-ietf-acme-ari-01 and known good + var certId = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c"; + var certIdCert = TestUtils.createCertificate("/certid-cert.pem"); + var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId); + + var provider = new TestableConnectionProvider() { + private boolean certRequested = 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 sendSignedRequest(URL url, JSONBuilder claims, Login login) { + assertThat(certRequested).isTrue(); + assertThat(url).isEqualTo(resourceUrl); + assertThatJson(claims.toString()).isEqualTo(getJSON("replacedCertificateRequest").toString()); + assertThat(login).isNotNull(); + return HttpURLConnection.HTTP_OK; + } + + @Override + public List readCertificates() { + assertThat(certRequested).isTrue(); + return certIdCert; + } + + @Override + public Collection getLinks(String relation) { + return Collections.emptyList(); + } + }; + + 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); + + cert.markAsReplaced(); + + provider.close(); + } + + /** + * Test that markAsReplaced() throws an exception if not supported. + */ + @Test + public void testMarkedAsReplacedThrowsIfNotSupported() throws AcmeException, IOException { + var certIdCert = TestUtils.createCertificate("/certid-cert.pem"); + + var provider = new TestableConnectionProvider() { + private boolean certRequested = false; + + @Override + public int sendCertificateRequest(URL url, Login login) { + assertThat(url).isEqualTo(locationUrl); + assertThat(login).isNotNull(); + certRequested = true; + return HttpURLConnection.HTTP_OK; + } + + @Override + public List readCertificates() { + assertThat(certRequested).isTrue(); + return certIdCert; + } + + @Override + public Collection getLinks(String relation) { + return Collections.emptyList(); + } + }; + + // We just need a dummy resource to create a directory + provider.putTestResource(Resource.NEW_ORDER, resourceUrl); + + assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> { + var cert = new Certificate(provider.createLogin(), locationUrl); + cert.markAsReplaced(); + }); + + provider.close(); + } + } diff --git a/acme4j-client/src/test/resources/json/replacedCertificateRequest.json b/acme4j-client/src/test/resources/json/replacedCertificateRequest.json new file mode 100644 index 00000000..fbe0a0e3 --- /dev/null +++ b/acme4j-client/src/test/resources/json/replacedCertificateRequest.json @@ -0,0 +1,4 @@ +{ + "certID": "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c", + "replaced": true +} \ No newline at end of file