mirror of https://github.com/shred/acme4j
Add ZeroSSL provider
As ZeroSSL makes use of the Retry-After header, the example implementation has also been changed accordingly.pull/168/head
parent
7118a454b2
commit
60342c435f
|
@ -37,6 +37,7 @@ module org.shredzone.acme4j {
|
||||||
provides org.shredzone.acme4j.provider.AcmeProvider
|
provides org.shredzone.acme4j.provider.AcmeProvider
|
||||||
with org.shredzone.acme4j.provider.GenericAcmeProvider,
|
with org.shredzone.acme4j.provider.GenericAcmeProvider,
|
||||||
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider,
|
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider,
|
||||||
|
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider,
|
||||||
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider,
|
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider,
|
||||||
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider;
|
org.shredzone.acme4j.provider.zerossl.ZeroSSLAcmeProvider;
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class LetsEncryptAcmeProvider extends AbstractAcmeProvider {
|
||||||
public URL resolve(URI serverUri) {
|
public URL resolve(URI serverUri) {
|
||||||
var path = serverUri.getPath();
|
var path = serverUri.getPath();
|
||||||
String directoryUrl;
|
String directoryUrl;
|
||||||
if (path == null || "".equals(path) || "/".equals(path) || "/v02".equals(path)) {
|
if (path == null || path.isEmpty() || "/".equals(path) || "/v02".equals(path)) {
|
||||||
directoryUrl = V02_DIRECTORY_URL;
|
directoryUrl = V02_DIRECTORY_URL;
|
||||||
} else if ("/staging".equals(path)) {
|
} else if ("/staging".equals(path)) {
|
||||||
directoryUrl = STAGING_DIRECTORY_URL;
|
directoryUrl = STAGING_DIRECTORY_URL;
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
* acme4j - Java ACME client
|
||||||
|
*
|
||||||
|
* Copyright (C) 2024 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.provider.zerossl;
|
||||||
|
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URL;
|
||||||
|
|
||||||
|
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||||
|
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
|
||||||
|
import org.shredzone.acme4j.provider.AcmeProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link AcmeProvider} for <em>ZeroSSL</em>.
|
||||||
|
* <p>
|
||||||
|
* The {@code serverUri} is {@code "acme://zerossl.com"} for the production server.
|
||||||
|
*
|
||||||
|
* @see <a href="https://zerossl.com/">ZeroSSL</a>
|
||||||
|
* @since 3.2.0
|
||||||
|
*/
|
||||||
|
public class ZeroSSLAcmeProvider extends AbstractAcmeProvider {
|
||||||
|
|
||||||
|
private static final String V02_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean accepts(URI serverUri) {
|
||||||
|
return "acme".equals(serverUri.getScheme())
|
||||||
|
&& "zerossl.com".equals(serverUri.getHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public URL resolve(URI serverUri) {
|
||||||
|
var path = serverUri.getPath();
|
||||||
|
String directoryUrl;
|
||||||
|
if (path == null || path.isEmpty() || "/".equals(path)) {
|
||||||
|
directoryUrl = V02_DIRECTORY_URL;
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Unknown URI " + serverUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(directoryUrl);
|
||||||
|
} catch (MalformedURLException ex) {
|
||||||
|
throw new AcmeProtocolException(directoryUrl, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* acme4j - Java ACME client
|
||||||
|
*
|
||||||
|
* Copyright (C) 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This package contains the ZeroSSL {@link org.shredzone.acme4j.provider.AcmeProvider}.
|
||||||
|
*
|
||||||
|
* @see <a href="https://zerossl.com/">ZeroSSL</a>
|
||||||
|
*/
|
||||||
|
@ReturnValuesAreNonnullByDefault
|
||||||
|
@DefaultAnnotationForParameters(NonNull.class)
|
||||||
|
@DefaultAnnotationForFields(NonNull.class)
|
||||||
|
package org.shredzone.acme4j.provider.zerossl;
|
||||||
|
|
||||||
|
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
|
||||||
|
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
|
||||||
|
import edu.umd.cs.findbugs.annotations.NonNull;
|
||||||
|
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;
|
|
@ -7,3 +7,7 @@ org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider
|
||||||
|
|
||||||
# SSL.com: https://ssl.com
|
# SSL.com: https://ssl.com
|
||||||
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider
|
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider
|
||||||
|
|
||||||
|
# ZeroSSL: https://zerossl.com
|
||||||
|
org.shredzone.acme4j.provider.zerossl.ZeroSSLAcmeProvider
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
/*
|
||||||
|
* acme4j - Java ACME client
|
||||||
|
*
|
||||||
|
* Copyright (C) 2024 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.provider.zerossl;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.shredzone.acme4j.toolbox.TestUtils.url;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
|
||||||
|
import org.assertj.core.api.AutoCloseableSoftAssertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link ZeroSSLAcmeProvider}.
|
||||||
|
*/
|
||||||
|
public class ZeroSSLAcmeProviderTest {
|
||||||
|
|
||||||
|
private static final String V02_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if the provider accepts the correct URIs.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testAccepts() throws URISyntaxException {
|
||||||
|
var provider = new ZeroSSLAcmeProvider();
|
||||||
|
|
||||||
|
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||||
|
softly.assertThat(provider.accepts(new URI("acme://zerossl.com"))).isTrue();
|
||||||
|
softly.assertThat(provider.accepts(new URI("acme://zerossl.com/"))).isTrue();
|
||||||
|
softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
|
||||||
|
softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse();
|
||||||
|
softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if acme URIs are properly resolved.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testResolve() throws URISyntaxException {
|
||||||
|
var provider = new ZeroSSLAcmeProvider();
|
||||||
|
|
||||||
|
assertThat(provider.resolve(new URI("acme://zerossl.com"))).isEqualTo(url(V02_DIRECTORY_URL));
|
||||||
|
assertThat(provider.resolve(new URI("acme://zerossl.com/"))).isEqualTo(url(V02_DIRECTORY_URL));
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://zerossl.com/v99")));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -21,9 +21,14 @@ import java.net.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.Security;
|
import java.security.Security;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.EnumSet;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
|
|
||||||
|
@ -40,6 +45,7 @@ import org.shredzone.acme4j.challenge.Challenge;
|
||||||
import org.shredzone.acme4j.challenge.Dns01Challenge;
|
import org.shredzone.acme4j.challenge.Dns01Challenge;
|
||||||
import org.shredzone.acme4j.challenge.Http01Challenge;
|
import org.shredzone.acme4j.challenge.Http01Challenge;
|
||||||
import org.shredzone.acme4j.exception.AcmeException;
|
import org.shredzone.acme4j.exception.AcmeException;
|
||||||
|
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||||
import org.shredzone.acme4j.util.KeyPairUtils;
|
import org.shredzone.acme4j.util.KeyPairUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
@ -68,6 +74,9 @@ public class ClientTest {
|
||||||
// RSA key size of generated key pairs
|
// RSA key size of generated key pairs
|
||||||
private static final int KEY_SIZE = 2048;
|
private static final int KEY_SIZE = 2048;
|
||||||
|
|
||||||
|
// Maximum attempts of status polling until VALID/INVALID is expected
|
||||||
|
private static final int MAX_ATTEMPTS = 50;
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
|
||||||
|
|
||||||
private enum ChallengeType {HTTP, DNS}
|
private enum ChallengeType {HTTP, DNS}
|
||||||
|
@ -106,27 +115,13 @@ public class ClientTest {
|
||||||
order.execute(domainKeyPair);
|
order.execute(domainKeyPair);
|
||||||
|
|
||||||
// Wait for the order to complete
|
// Wait for the order to complete
|
||||||
try {
|
Status status = waitForCompletion(order::getStatus, order::update);
|
||||||
int attempts = 10;
|
if (status != Status.VALID) {
|
||||||
while (order.getStatus() != Status.VALID && attempts-- > 0) {
|
LOG.error("Order has failed, reason: {}", order.getError()
|
||||||
// Did the order fail?
|
.map(Problem::toString)
|
||||||
if (order.getStatus() == Status.INVALID) {
|
.orElse("unknown")
|
||||||
LOG.error("Order has failed, reason: {}", order.getError()
|
);
|
||||||
.map(Problem::toString)
|
throw new AcmeException("Order failed... Giving up.");
|
||||||
.orElse("unknown")
|
|
||||||
);
|
|
||||||
throw new AcmeException("Order failed... Giving up.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for a few seconds
|
|
||||||
Thread.sleep(3000L);
|
|
||||||
|
|
||||||
// Then update the status
|
|
||||||
order.update();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
LOG.error("interrupted", ex);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the certificate
|
// Get the certificate
|
||||||
|
@ -260,32 +255,12 @@ public class ClientTest {
|
||||||
challenge.trigger();
|
challenge.trigger();
|
||||||
|
|
||||||
// Poll for the challenge to complete.
|
// Poll for the challenge to complete.
|
||||||
try {
|
Status status = waitForCompletion(challenge::getStatus, challenge::update);
|
||||||
int attempts = 10;
|
if (status != Status.VALID) {
|
||||||
while (challenge.getStatus() != Status.VALID && attempts-- > 0) {
|
LOG.error("Challenge has failed, reason: {}", challenge.getError()
|
||||||
// Did the authorization fail?
|
.map(Problem::toString)
|
||||||
if (challenge.getStatus() == Status.INVALID) {
|
.orElse("unknown"));
|
||||||
LOG.error("Challenge has failed, reason: {}", challenge.getError()
|
throw new AcmeException("Challenge failed... Giving up.");
|
||||||
.map(Problem::toString)
|
|
||||||
.orElse("unknown"));
|
|
||||||
throw new AcmeException("Challenge failed... Giving up.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for a few seconds
|
|
||||||
Thread.sleep(3000L);
|
|
||||||
|
|
||||||
// Then update the status
|
|
||||||
challenge.update();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
LOG.error("interrupted", ex);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
|
|
||||||
// All reattempts are used up and there is still no valid authorization?
|
|
||||||
if (challenge.getStatus() != Status.VALID) {
|
|
||||||
throw new AcmeException("Failed to pass the challenge for domain "
|
|
||||||
+ auth.getIdentifier().getDomain() + ", ... Giving up.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("Challenge has been completed. Remember to remove the validation resource.");
|
LOG.info("Challenge has been completed. Remember to remove the validation resource.");
|
||||||
|
@ -370,6 +345,76 @@ public class ClientTest {
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for completion of a resource. A resource is completed if the status is either
|
||||||
|
* {@link Status#VALID} or {@link Status#INVALID}.
|
||||||
|
* <p>
|
||||||
|
* This method polls the current status, respecting the retry-after header if set. It
|
||||||
|
* is synchronous and may take a considerable time for completion.
|
||||||
|
* <p>
|
||||||
|
* It is meant as a simple example! For production services, it is recommended to do
|
||||||
|
* an asynchronous processing here.
|
||||||
|
*
|
||||||
|
* @param statusSupplier
|
||||||
|
* Method of the resource that returns the current status
|
||||||
|
* @param statusUpdater
|
||||||
|
* Method of the resource that updates the internal state and fetches the
|
||||||
|
* current status from the server
|
||||||
|
* @return The final status, either {@link Status#VALID} or {@link Status#INVALID}
|
||||||
|
* @throws AcmeException
|
||||||
|
* If an error occured, or if the status did not reach one of the accepted
|
||||||
|
* result values after a certain number of checks.
|
||||||
|
*/
|
||||||
|
private Status waitForCompletion(Supplier<Status> statusSupplier, UpdateMethod statusUpdater)
|
||||||
|
throws AcmeException {
|
||||||
|
// A set of terminating status values
|
||||||
|
Set<Status> acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
|
||||||
|
|
||||||
|
// Limit the number of checks, to avoid endless loops
|
||||||
|
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
|
||||||
|
|
||||||
|
// A reasonable default retry-after delay
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant retryAfter = now.plusSeconds(3L);
|
||||||
|
|
||||||
|
// Update the status property
|
||||||
|
try {
|
||||||
|
statusUpdater.update();
|
||||||
|
} catch (AcmeRetryAfterException ex) {
|
||||||
|
// Server sent a retry-after header, use this instant instead
|
||||||
|
LOG.info("Server asks to try again at: {}", ex.getRetryAfter());
|
||||||
|
retryAfter = ex.getRetryAfter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the status
|
||||||
|
Status currentStatus = statusSupplier.get();
|
||||||
|
if (acceptableStatus.contains(currentStatus)) {
|
||||||
|
// Reached VALID or INVALID, we're done here
|
||||||
|
return currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before checking again
|
||||||
|
try {
|
||||||
|
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AcmeException("interrupted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AcmeException("Too many update attempts, status did not change");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functional interface that refers to a resource update method that is able to
|
||||||
|
* throw an {@link AcmeException}.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface UpdateMethod {
|
||||||
|
void update() throws AcmeException;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presents the instructions for preparing the challenge validation, and waits for
|
* Presents the instructions for preparing the challenge validation, and waits for
|
||||||
* dismissal. If the user cancelled the dialog, an exception is thrown.
|
* dismissal. If the user cancelled the dialog, an exception is thrown.
|
||||||
|
|
|
@ -9,10 +9,11 @@ The _acme4j_ package contains these providers:
|
||||||
* [Let's Encrypt](letsencrypt.md)
|
* [Let's Encrypt](letsencrypt.md)
|
||||||
* [Pebble](pebble.md)
|
* [Pebble](pebble.md)
|
||||||
* [SSL.com](sslcom.md)
|
* [SSL.com](sslcom.md)
|
||||||
|
* [ZeroSSL](zerossl.md)
|
||||||
|
|
||||||
More CAs may be supported in future releases of _acme4j_.
|
More CAs may be supported in future releases of _acme4j_.
|
||||||
|
|
||||||
Also, CAs can publish provider jar files that plug into _acme4j_ and offer extended support.
|
Also, CAs can publish provider jar files that plug into _acme4j_ and offer extended support.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
You can always connect to any ACMEv2 compliant server, by passing the `URL` of its directory service to the `Session`.
|
You can always connect to any [RFC 8555](https://tools.ietf.org/html/rfc8555) compliant server, by passing the `URL` of its directory endpoint to the `Session`.
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
Web site: [SSL.com](https://ssl.com)
|
Web site: [SSL.com](https://ssl.com)
|
||||||
|
|
||||||
|
Available since acme4j 3.2.0
|
||||||
|
|
||||||
## Connection URIs
|
## Connection URIs
|
||||||
|
|
||||||
* `acme://ssl.com` - Production server
|
* `acme://ssl.com` - Production server
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
# ZeroSSL
|
||||||
|
|
||||||
|
Web site: [ZeroSSL](https://zerossl.com)
|
||||||
|
|
||||||
|
Available since acme4j 3.2.0
|
||||||
|
|
||||||
|
## Connection URIs
|
||||||
|
|
||||||
|
* `acme://zerossl.com` - Production server
|
||||||
|
|
||||||
|
ZeroSSL does not provide a staging server (as of Feburary 2024).
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
* ZeroSSL requires account creation with [key identifier](../usage/account.md#external-account-binding).
|
||||||
|
* ZeroSSL makes use of the retry-after header, so expect [AcmeRetryAfterException](../usage/exceptions.md#acmeretryafterexception)s to be thrown, and handle them accordingly (see example).
|
||||||
|
* Certificate creation can take a considerable amount of time (up to 24h). The retry-after header still gives a short retry period, resulting in a very high number of status update reattempts.
|
||||||
|
* Server response can be very slow sometimes. It is recommended to set a timeout of 30 seconds or higher in the [network settings](../usage/advanced.md#network-settings).
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
If you have used the [example code](../example.md) of _acme4j_ before version 3.2.0, please review the updated example for how to use ZeroSSL with _acme4j_.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
_acme4j_ is not officially supported or endorsed by ZeroSSL. If you have _acme4j_ related issues, please do not ask them for support, but [open an issue here](https://github.com/shred/acme4j/issues).
|
|
@ -3,17 +3,13 @@
|
||||||
Basically, it is possible to connect to any kind of ACME server just by connecting to the URL of its directory resource:
|
Basically, it is possible to connect to any kind of ACME server just by connecting to the URL of its directory resource:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Session session = new Session("https://acme-v02.api.letsencrypt.org/directory");
|
Session session = new Session("https://api.example.org/directory");
|
||||||
```
|
```
|
||||||
|
|
||||||
ACME providers are "plug-ins" to _acme4j_ that are specialized on a single CA. For example, the _Let's Encrypt_ and _SSL.com_ providers offers URIs that are much easier to remember. The example above would look like this:
|
ACME providers are "plug-ins" to _acme4j_ that are specialized on a single CA. The example above would then look like this (if the CA is supported by _acme4j_):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Session session = new Session("acme://letsencrypt.org");
|
Session session = new Session("acme://example.org");
|
||||||
```
|
|
||||||
or this:
|
|
||||||
```java
|
|
||||||
Session session = new Session("acme://ssl.com");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Writing your own Provider
|
## Writing your own Provider
|
||||||
|
|
|
@ -8,9 +8,9 @@ This chapter contains a copy of the class file, along with explanations about wh
|
||||||
|
|
||||||
- The `ClientTest` is meant to be a simple example and proof of concept. It is not meant for production use as it is.
|
- The `ClientTest` is meant to be a simple example and proof of concept. It is not meant for production use as it is.
|
||||||
|
|
||||||
- The exception handling is very simple. If an exception occurs during the process, the example will fail altogether. A real client should handle exceptions like `AcmeUserActionRequiredException`, `AcmeRateLimitedException`, and `AcmeRetryAfterException` properly, by showing the required user action, or delaying the registration process until the rate limitation has been lifted or the retry time has been reached.
|
- The exception handling is very simple. If an exception occurs during the process, the example will fail altogether. A real client should handle exceptions like `AcmeUserActionRequiredException` and `AcmeRateLimitedException` properly, by showing the required user action, or delaying the registration process until the rate limitation has been lifted or the retry time has been reached.
|
||||||
|
|
||||||
- At some places the example polls the server state by `while` loops and `Thread.sleep()`. This is sufficient for simple cases, but a more complex client should use timers instead. The client should also make use of the fact that authorizations can be executed in parallel, shortening the certification time for multiple domains.
|
- At some places the example synchronously polls the server state. This is sufficient for simple cases, but a more complex client should use timers instead. The client should also make use of the fact that authorizations can be executed in parallel, shortening the certification time for multiple domains.
|
||||||
|
|
||||||
- I recommend to read at least the chapters about [usage](usage/index.md) and [challenges](challenge/index.md), to learn more about how _acme4j_ and the ACME protocol works.
|
- I recommend to read at least the chapters about [usage](usage/index.md) and [challenges](challenge/index.md), to learn more about how _acme4j_ and the ACME protocol works.
|
||||||
|
|
||||||
|
@ -88,27 +88,13 @@ public void fetchCertificate(Collection<String> domains)
|
||||||
order.execute(domainKeyPair);
|
order.execute(domainKeyPair);
|
||||||
|
|
||||||
// Wait for the order to complete
|
// Wait for the order to complete
|
||||||
try {
|
Status status = waitForCompletion(order::getStatus, order::update);
|
||||||
int attempts = 10;
|
if (status != Status.VALID) {
|
||||||
while (order.getStatus() != Status.VALID && attempts-- > 0) {
|
LOG.error("Order has failed, reason: {}", order.getError()
|
||||||
// Did the order fail?
|
.map(Problem::toString)
|
||||||
if (order.getStatus() == Status.INVALID) {
|
.orElse("unknown")
|
||||||
LOG.error("Order has failed, reason: {}", order.getError()
|
);
|
||||||
.map(Problem::toString)
|
throw new AcmeException("Order failed... Giving up.");
|
||||||
.orElse("unknown")
|
|
||||||
);
|
|
||||||
throw new AcmeException("Order failed... Giving up.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for a few seconds
|
|
||||||
Thread.sleep(3000L);
|
|
||||||
|
|
||||||
// Then update the status
|
|
||||||
order.update();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
LOG.error("interrupted", ex);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the certificate
|
// Get the certificate
|
||||||
|
@ -250,27 +236,12 @@ private void authorize(Authorization auth)
|
||||||
challenge.trigger();
|
challenge.trigger();
|
||||||
|
|
||||||
// Poll for the challenge to complete.
|
// Poll for the challenge to complete.
|
||||||
try {
|
Status status = waitForCompletion(challenge::getStatus, challenge::update);
|
||||||
int attempts = 10;
|
if (status != Status.VALID) {
|
||||||
while (challenge.getStatus() != Status.VALID && attempts-- > 0) {
|
LOG.error("Challenge has failed, reason: {}", challenge.getError()
|
||||||
// Did the authorization fail?
|
.map(Problem::toString)
|
||||||
if (challenge.getStatus() == Status.INVALID) {
|
.orElse("unknown"));
|
||||||
LOG.error("Challenge has failed, reason: {}", challenge.getError()
|
throw new AcmeException("Challenge failed... Giving up.");
|
||||||
.map(Problem::toString)
|
|
||||||
.orElse("unknown")
|
|
||||||
);
|
|
||||||
throw new AcmeException("Challenge failed... Giving up.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for a few seconds
|
|
||||||
Thread.sleep(3000L);
|
|
||||||
|
|
||||||
// Then update the status
|
|
||||||
challenge.update();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
LOG.error("interrupted", ex);
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All reattempts are used up and there is
|
// All reattempts are used up and there is
|
||||||
|
@ -365,6 +336,66 @@ public Challenge dnsChallenge(Authorization auth) throws AcmeException {
|
||||||
!!! note
|
!!! note
|
||||||
For security reasons, the DNS challenge is mandatory for creating wildcard certificates.
|
For security reasons, the DNS challenge is mandatory for creating wildcard certificates.
|
||||||
|
|
||||||
|
## Checking the Status
|
||||||
|
|
||||||
|
The ACME protocol does not specify the sending of events. For this reason, resource status changes must be actively polled by the client.
|
||||||
|
|
||||||
|
This example does a very simple polling in a synchronous busy loop. It updates the local copy of the resource and checks if the status is either `VALID` or `INVALID`. If it is not, it just sleeps for a certain amount of time, and then rechecks the current status.
|
||||||
|
|
||||||
|
Some CAs respond with a `Retry-After` HTTP header, which provides a recommendation when to check for a status change again. If this header is present, _acme4j_ will throw an `AcmeRetryAfterException` (and will still update the resource state). If this header is not present, just wait a reasonable amount of time before checking again.
|
||||||
|
|
||||||
|
An enterprise level implementation would do an asynchronous polling by storing the recheck time in a database or a queue with scheduled delivery.
|
||||||
|
|
||||||
|
The following method will check if a resource reaches completion (by reaching either `VALID` or `INVALID` status). The first parameter provides the method that fetches the current status (e.g. `Order::getStatus`). The second parameter provides the method that updates the resource status (e.g. `Order::update()`). It returned the terminating status once it has been reached, or will throw an exception if something went wrong.
|
||||||
|
|
||||||
|
```java
|
||||||
|
private Status waitForCompletion(Supplier<Status> statusSupplier,
|
||||||
|
UpdateMethod statusUpdater) throws AcmeException {
|
||||||
|
// A set of terminating status values
|
||||||
|
Set<Status> acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
|
||||||
|
|
||||||
|
// Limit the number of checks, to avoid endless loops
|
||||||
|
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
|
||||||
|
|
||||||
|
// A reasonable default retry-after delay
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant retryAfter = now.plusSeconds(3L);
|
||||||
|
|
||||||
|
// Update the status property
|
||||||
|
try {
|
||||||
|
statusUpdater.update();
|
||||||
|
} catch (AcmeRetryAfterException ex) {
|
||||||
|
// Server sent a retry-after header, use this instant instead
|
||||||
|
LOG.info("Server asks to try again at: {}", ex.getRetryAfter());
|
||||||
|
retryAfter = ex.getRetryAfter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the status
|
||||||
|
Status currentStatus = statusSupplier.get();
|
||||||
|
if (acceptableStatus.contains(currentStatus)) {
|
||||||
|
// Reached VALID or INVALID, we're done here
|
||||||
|
return currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before checking again
|
||||||
|
try {
|
||||||
|
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AcmeException("interrupted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AcmeException("Too many update attempts, status did not change");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface UpdateMethod {
|
||||||
|
void update() throws AcmeException;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## User Interaction
|
## User Interaction
|
||||||
|
|
||||||
In order to keep the example simple, Swing `JOptionPane` dialogs are used for user communication. If the user rejects a dialog, an exception is thrown and the example client is aborted.
|
In order to keep the example simple, Swing `JOptionPane` dialogs are used for user communication. If the user rejects a dialog, an exception is thrown and the example client is aborted.
|
||||||
|
@ -420,4 +451,7 @@ private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
|
||||||
|
|
||||||
// RSA key size of generated key pairs
|
// RSA key size of generated key pairs
|
||||||
private static final int KEY_SIZE = 2048;
|
private static final int KEY_SIZE = 2048;
|
||||||
|
|
||||||
|
// Maximum attempts of status polling until VALID/INVALID is expected
|
||||||
|
private static final int MAX_ATTEMPTS = 50;
|
||||||
```
|
```
|
||||||
|
|
|
@ -35,6 +35,14 @@ You can still revoke certificates without account key pair though, see [here](us
|
||||||
|
|
||||||
**Solution:** If the status is `INVALID`, invoke `Order.getError()` to get the cause of the failure. For example, you can log the output of `order.getError().toString()`.
|
**Solution:** If the status is `INVALID`, invoke `Order.getError()` to get the cause of the failure. For example, you can log the output of `order.getError().toString()`.
|
||||||
|
|
||||||
|
## My `Order` seems to be stuck in status `PROCESSING`. What can I do?
|
||||||
|
|
||||||
|
**Symptom:** Your challenge(s) passed as `VALID`. However when you execute the order, it seems to be stuck in status `PROCESSING`.
|
||||||
|
|
||||||
|
**Cause:** The CA may have retained your order to carry out background checks. These checks can take hours or even days. Please read the CA documentation for further details.
|
||||||
|
|
||||||
|
**Solution:** There is nothing you can do on software side.
|
||||||
|
|
||||||
## Browsers do not accept my certificate.
|
## Browsers do not accept my certificate.
|
||||||
|
|
||||||
**Symptom:** A certificate was successfully issued. However the browser does not accept the certificate, and shows an error that the cert authority is invalid.
|
**Symptom:** A certificate was successfully issued. However the browser does not accept the certificate, and shows an error that the cert authority is invalid.
|
||||||
|
|
|
@ -6,39 +6,38 @@ The first step is to create such a `Session` instance.
|
||||||
|
|
||||||
## Standard URIs
|
## Standard URIs
|
||||||
|
|
||||||
The `Session` constructor expects the URI of the ACME server's _directory_, as it is documented by the CA. For example, this is how to connect to the _Let's Encrypt_ staging server.
|
The `Session` constructor expects the URI of the ACME server's _directory_, as it is documented by the CA. This is how to connect to a fictional example staging server:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Session session
|
Session session
|
||||||
= new Session("https://acme-staging-v02.api.letsencrypt.org/directory");
|
= new Session("https://acme-staging-v02.api.example.org/directory");
|
||||||
```
|
```
|
||||||
|
|
||||||
The Session now knows where to locate the service endpoints. However, no actual connection to the server is done yet. The connection to the CA is handled later by a generic provider.
|
The Session now knows where to locate the service endpoints. However, no actual connection to the server is done yet. The connection to the CA is handled later by a generic provider.
|
||||||
|
|
||||||
## ACME URIs
|
## ACME URIs
|
||||||
|
|
||||||
Such an URI is hard to remember and might even change in the future. For this reason, special ACME URIs should be preferred (if available):
|
Such an URI is hard to remember and might even change in the future. For this reason, special ACME connection URIs should be preferred. These special ACME URIs look like this:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Session session = new Session("acme://letsencrypt.org/staging");
|
Session session = new Session("acme://example.org/staging");
|
||||||
```
|
|
||||||
or
|
|
||||||
```java
|
|
||||||
Session session = new Session("acme://ssl.com/staging");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Instead of a generic provider, this call uses a specialized _Let's Encrypt_ provider.
|
Instead of a generic provider, this call uses a provider that is specialized to the CA.
|
||||||
|
|
||||||
The _Let's Encrypt_ staging server is meant to be used for testing purposes only. The issued certificates are functional, but as the issuer certificate is not known to browsers, it will lead to an error if the certificate is validated.
|
!!! note
|
||||||
|
<span style="font-size:120%">**→ [Find the ACME Connection URI of your CA here!](../ca/index.md) ←**</span>
|
||||||
|
|
||||||
To use the _Let's Encrypt_ production server, you only need to change the ACME URI:
|
If your CA is not listed there, it might still provide a JAR file with a proprietary provider that you can add to the classpath.
|
||||||
|
|
||||||
|
**You can always use the standard URI (as mentioned above) to connect to any [RFC 8555](https://tools.ietf.org/html/rfc8555) compliant CA.**
|
||||||
|
|
||||||
|
A staging server is meant to be used for testing purposes only. The issued certificates are functional, but as the issuer certificate is not known to browsers, it will lead to an error if the certificate is validated.
|
||||||
|
|
||||||
|
To use the production server, you only need to change the ACME URI:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Session session = new Session("acme://letsencrypt.org");
|
Session session = new Session("acme://example.org");
|
||||||
```
|
|
||||||
or to use the _SSL.com_ production server:
|
|
||||||
```java
|
|
||||||
Session session = new Session("acme://ssl.com");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
|
@ -81,7 +81,7 @@ This is a very simple example which can be improved in many ways:
|
||||||
* Limit the number of checks, to avoid endless loops if an authorization is stuck on server side.
|
* Limit the number of checks, to avoid endless loops if an authorization is stuck on server side.
|
||||||
* Wait with the status checks until the CA has accessed the response for the first time (e.g. after an incoming HTTP request to the response file).
|
* Wait with the status checks until the CA has accessed the response for the first time (e.g. after an incoming HTTP request to the response file).
|
||||||
* Use an asynchronous architecture instead of a blocking `Thread.sleep()`.
|
* Use an asynchronous architecture instead of a blocking `Thread.sleep()`.
|
||||||
* Check if `auth.update()` throws an `AcmeRetryAfterException`, and wait for the next update until `AcmeRetryAfterException.getRetryAfter()`. (The state of the `Authorization` instance is still updated when this exception is thrown.)
|
* Check if `auth.update()` throws an `AcmeRetryAfterException`, and wait for the next update until `AcmeRetryAfterException.getRetryAfter()`. See the [example](../example.md) for a simple way to do that.
|
||||||
|
|
||||||
The CA server may start with the validation immediately after `trigger()` is invoked, so make sure your server is ready to respond to requests before invoking `trigger()`. Otherwise the challenge might fail instantly.
|
The CA server may start with the validation immediately after `trigger()` is invoked, so make sure your server is ready to respond to requests before invoking `trigger()`. Otherwise the challenge might fail instantly.
|
||||||
|
|
||||||
|
@ -143,7 +143,7 @@ This is a very simple example which can be improved in many ways:
|
||||||
|
|
||||||
* Limit the number of checks, to avoid endless loops if the order is stuck on server side.
|
* Limit the number of checks, to avoid endless loops if the order is stuck on server side.
|
||||||
* Use an asynchronous architecture instead of a blocking `Thread.sleep()`.
|
* Use an asynchronous architecture instead of a blocking `Thread.sleep()`.
|
||||||
* Check if `order.update()` throws an `AcmeRetryAfterException`, and wait for the next update until `AcmeRetryAfterException.getRetryAfter()`. (The state of the `Order` instance is still updated when this exception is thrown.)
|
* Check if `order.update()` throws an `AcmeRetryAfterException`, and wait for the next update until `AcmeRetryAfterException.getRetryAfter()`. See the [example](../example.md) for a simple way to do that.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
If the status is `PENDING`, you have not completed all authorizations yet.
|
If the status is `PENDING`, you have not completed all authorizations yet.
|
||||||
|
|
|
@ -45,6 +45,7 @@ nav:
|
||||||
- 'ca/letsencrypt.md'
|
- 'ca/letsencrypt.md'
|
||||||
- 'ca/pebble.md'
|
- 'ca/pebble.md'
|
||||||
- 'ca/sslcom.md'
|
- 'ca/sslcom.md'
|
||||||
|
- 'ca/zerossl.md'
|
||||||
- Development:
|
- Development:
|
||||||
- 'development/index.md'
|
- 'development/index.md'
|
||||||
- 'development/provider.md'
|
- 'development/provider.md'
|
||||||
|
|
Loading…
Reference in New Issue