mirror of https://github.com/shred/acme4j
Compare commits
122 Commits
Author | SHA1 | Date |
---|---|---|
![]() |
dc7182ca1f | |
![]() |
294d977757 | |
![]() |
03f97d6bcb | |
![]() |
88fe967bbf | |
![]() |
0c660946d0 | |
![]() |
6692e5bf8e | |
![]() |
2ca2f4b264 | |
![]() |
2a5df329bd | |
![]() |
ec726f6859 | |
![]() |
4829d0e70c | |
![]() |
033f9701c0 | |
![]() |
b62709470e | |
![]() |
29c6dc97a1 | |
![]() |
ce60dc9368 | |
![]() |
feee96444f | |
![]() |
ba50d4ec72 | |
![]() |
1dc3c7ad64 | |
![]() |
c0d96e709e | |
![]() |
1ed293c5bb | |
![]() |
1069bcc2ce | |
![]() |
9f07272180 | |
![]() |
8678f80ac6 | |
![]() |
333f798a72 | |
![]() |
9d2087d2a6 | |
![]() |
5c762dfe48 | |
![]() |
3adab36f05 | |
![]() |
8bb6560ff8 | |
![]() |
008ffc968f | |
![]() |
6b0b0e68b6 | |
![]() |
f6a3bd618b | |
![]() |
0364acead3 | |
![]() |
dec4a461ca | |
![]() |
786a2d279d | |
![]() |
36363adfe2 | |
![]() |
83d6f38ec7 | |
![]() |
43b6a7c7c6 | |
![]() |
c0fede3b1a | |
![]() |
6e9c266b17 | |
![]() |
c85f4a627b | |
![]() |
19371229b8 | |
![]() |
318aeaab9d | |
![]() |
6a24d85364 | |
![]() |
7a02a2f857 | |
![]() |
c6f6ee9d07 | |
![]() |
e88b4ef68f | |
![]() |
d9186ede14 | |
![]() |
87bbb9efbf | |
![]() |
beec5156c2 | |
![]() |
0ccd68c09a | |
![]() |
afa60ae76f | |
![]() |
e589b16d98 | |
![]() |
793bcd7ce1 | |
![]() |
21751be264 | |
![]() |
171ee474c0 | |
![]() |
05d826d83e | |
![]() |
b897dc277d | |
![]() |
ae60431a79 | |
![]() |
a9ce33a921 | |
![]() |
a85ff19cf8 | |
![]() |
2bbe5c5815 | |
![]() |
5788b0e6dd | |
![]() |
514b188c69 | |
![]() |
6120a2b476 | |
![]() |
01249294c8 | |
![]() |
f9768d1793 | |
![]() |
feb3d59f7b | |
![]() |
a718d82db2 | |
![]() |
5b14d15854 | |
![]() |
6d5da63b8e | |
![]() |
aeff12088f | |
![]() |
57ec36054a | |
![]() |
4f36055be5 | |
![]() |
773cacde4f | |
![]() |
b5a7e00ac3 | |
![]() |
97a6708db3 | |
![]() |
565eab9fa4 | |
![]() |
e97ced5e45 | |
![]() |
511954171d | |
![]() |
bbc057b81f | |
![]() |
65e6e28bff | |
![]() |
c16d1a45cc | |
![]() |
fdbd82e887 | |
![]() |
d40e30ab56 | |
![]() |
d57f4abb60 | |
![]() |
f9d479a8f7 | |
![]() |
908e11b152 | |
![]() |
081e53f137 | |
![]() |
98ef2b8466 | |
![]() |
73c71be754 | |
![]() |
f2ae26b822 | |
![]() |
7c17645212 | |
![]() |
c0b74bfc59 | |
![]() |
60342c435f | |
![]() |
7118a454b2 | |
![]() |
3a8a905d87 | |
![]() |
9c6eb5e610 | |
![]() |
48c32f612d | |
![]() |
6a4770c23a | |
![]() |
edb7ec83b6 | |
![]() |
216d30b600 | |
![]() |
67a90df47f | |
![]() |
50a74251e0 | |
![]() |
278f9bd57b | |
![]() |
beb1d53dc0 | |
![]() |
78ccae6bc9 | |
![]() |
1cf53b6cf4 | |
![]() |
e26f8fc572 | |
![]() |
f9b3242f4c | |
![]() |
e3cc271cd8 | |
![]() |
f428f1be9c | |
![]() |
86c2647ff0 | |
![]() |
be7e9a690a | |
![]() |
a9bfc8b46e | |
![]() |
04fe10c55b | |
![]() |
e041decf48 | |
![]() |
78d73d96aa | |
![]() |
f61ef3ede7 | |
![]() |
5ef39534ec | |
![]() |
2485666b87 | |
![]() |
3ad325782b | |
![]() |
4da80d4da7 | |
![]() |
dd7c873750 |
|
@ -13,7 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
java: [ '11', '17' ]
|
||||
java: [ '17', '21' ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK
|
||||
|
|
|
@ -43,7 +43,7 @@ Good programming does not end with a clean source code, but should have pretty c
|
|||
* Always put separate concerns into separate commits.
|
||||
* If you have interim commits in your history, squash them with an interactive rebase before sending the pull request.
|
||||
* Use present tense and imperative mood in commit messages ("fix bug #1234", not "fixed bug #1234").
|
||||
* Always give meaningful commit messages (not just "bugfix").
|
||||
* Always give meaningful commit messages (not just "bug fix").
|
||||
* The commit message must be concise and should not exceed 50 characters. Further explanations may follow in subsequent lines, with an empty line as separator.
|
||||
* Commits must compile and must not break unit tests.
|
||||
* Do not commit IDE generated files and directories (like `.project` or `.idea`).
|
||||
|
|
26
README.md
26
README.md
|
@ -4,9 +4,7 @@ This is a Java client for the _Automatic Certificate Management Environment_ (AC
|
|||
|
||||
ACME is a protocol that a certificate authority (CA) and an applicant can use to automate the process of verification and certificate issuance.
|
||||
|
||||
This Java client helps connecting to an ACME server, and performing all necessary steps to manage certificates.
|
||||
|
||||
It is an independent open source implementation that is not affiliated with or endorsed by _Let's Encrypt_.
|
||||
This Java client helps to connect to an ACME server, and performing all necessary steps to manage certificates.
|
||||
|
||||
## Features
|
||||
|
||||
|
@ -16,15 +14,17 @@ 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
|
||||
* Supports [RFC 9444](https://tools.ietf.org/html/rfc9444) for subdomain validation
|
||||
* Supports [RFC 9773](https://tools.ietf.org/html/rfc9773) for renewal information
|
||||
* Supports [draft-aaron-acme-profiles-01](https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/) for certificate profiles (experimental)
|
||||
* Supports [draft-ietf-acme-dns-account-label-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-label/) for DNS labeled with ACME account ID challenges (experimental)
|
||||
* Easy to use Java API
|
||||
* Requires JRE 11 or higher
|
||||
* Requires JRE 17 or higher
|
||||
* Supports [Actalis](https://www.actalis.com/), [Buypass](https://buypass.com/), [Google Trust Services](https://pki.goog/), [Let's Encrypt](https://letsencrypt.org/), [SSL.com](https://www.ssl.com/), [ZeroSSL](https://zerossl.com/), and **all other CAs that comply with the ACME protocol (RFC 8555)**. Note that _acme4j_ is an independent project that is not supported or endorsed by any of the CAs.
|
||||
* Built with maven, packages available at [Maven Central](http://search.maven.org/#search|ga|1|g%3A%22org.shredzone.acme4j%22)
|
||||
* Extensive unit and integration tests
|
||||
* Adheres to [Semantic Versioning](https://semver.org/)
|
||||
|
||||
If you require Java 8 or Android compatibility, you can use [acme4j v2](https://shredzone.org/maven/acme4j-v2/index.html) instead. However, v2 is not actively developed anymore and will only receive security fixes.
|
||||
|
||||
## Dependencies
|
||||
|
||||
* [Bouncy Castle](https://www.bouncycastle.org/)
|
||||
|
@ -35,7 +35,7 @@ If you require Java 8 or Android compatibility, you can use [acme4j v2](https://
|
|||
## Usage
|
||||
|
||||
* See the [online documentation](https://shredzone.org/maven/acme4j/) about how to use _acme4j_.
|
||||
* For a quick start, have a look at [the source code of an example](https://shredzone.org/maven/acme4j/example.html).
|
||||
* For a quick start, take a look at [the source code of an example](https://shredzone.org/maven/acme4j/example.html).
|
||||
|
||||
## Announcements
|
||||
|
||||
|
@ -46,21 +46,17 @@ Follow our Mastodon feed for release notes and other acme4j related news.
|
|||
|
||||
## Contribute
|
||||
|
||||
* Fork the [Source code at GitHub](https://github.com/shred/acme4j). Feel free to send pull requests (see [Contributing](CONTRIBUTING.md) for the rules).
|
||||
* Found a bug? [File a bug report!](https://github.com/shred/acme4j/issues)
|
||||
* Fork the [Source code at Codeberg](https://codeberg.org/shred/acme4j) or [GitHub](https://github.com/shred/acme4j). Feel free to send pull requests (see [Contributing](CONTRIBUTING.md) for the rules).
|
||||
* Found a bug? [File a bug report!](https://codeberg.org/shred/acme4j/issues) ([GitHub](https://github.com/shred/acme4j/issues))
|
||||
|
||||
## License
|
||||
|
||||
_acme4j_ is open source software. The source code is distributed under the terms of [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
## Donate
|
||||
|
||||
If you would like to support my work on _acme4j_, you can do so on at [GitHub Sponsors](https://github.com/sponsors/shred) or at [Ko-Fi](https://ko-fi.com/shredzone). Thank you!
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
* I would like to thank Brian Campbell and all the other [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home) developers. _acme4j_ would not exist without your excellent work.
|
||||
* Thanks to [Daniel McCarney](https://github.com/cpu) for his help with the ACME protocol, Pebble, and Boulder.
|
||||
* [Ulrich Krause](https://github.com/eknori) for his help to make _acme4j_ run on IBM Java VMs.
|
||||
* I also like to thank [everyone who contributed to _acme4j_](https://github.com/shred/acme4j/graphs/contributors).
|
||||
* I also like to thank [everyone who contributed to _acme4j_](https://codeberg.org/shred/acme4j/activity/contributors).
|
||||
* The Mastodon account is hosted by [foojay.io](https://foojay.io).
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<parent>
|
||||
<groupId>org.shredzone.acme4j</groupId>
|
||||
<artifactId>acme4j</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<version>3.5.2-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>acme4j-client</artifactId>
|
||||
|
|
|
@ -36,6 +36,11 @@ module org.shredzone.acme4j {
|
|||
|
||||
provides org.shredzone.acme4j.provider.AcmeProvider
|
||||
with org.shredzone.acme4j.provider.GenericAcmeProvider,
|
||||
org.shredzone.acme4j.provider.actalis.ActalisAcmeProvider,
|
||||
org.shredzone.acme4j.provider.buypass.BuypassAcmeProvider,
|
||||
org.shredzone.acme4j.provider.google.GoogleAcmeProvider,
|
||||
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider,
|
||||
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider;
|
||||
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider,
|
||||
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider,
|
||||
org.shredzone.acme4j.provider.zerossl.ZeroSSLAcmeProvider;
|
||||
}
|
||||
|
|
|
@ -13,8 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.stream.Collectors.toUnmodifiableList;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URI;
|
||||
import java.security.KeyPair;
|
||||
import java.util.ArrayList;
|
||||
|
@ -24,6 +23,7 @@ import java.util.List;
|
|||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.shredzone.acme4j.connector.Resource;
|
||||
import org.shredzone.acme4j.connector.ResourceIterator;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
|
@ -41,6 +41,7 @@ import org.slf4j.LoggerFactory;
|
|||
* A representation of an account at the ACME server.
|
||||
*/
|
||||
public class Account extends AcmeJsonResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 7042863483428051319L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Account.class);
|
||||
|
||||
|
@ -75,7 +76,7 @@ public class Account extends AcmeJsonResource {
|
|||
.asArray()
|
||||
.stream()
|
||||
.map(Value::asURI)
|
||||
.collect(toUnmodifiableList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -189,6 +190,11 @@ public class Account extends AcmeJsonResource {
|
|||
|
||||
var newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
|
||||
|
||||
if (identifier.toMap().containsKey(Identifier.KEY_SUBDOMAIN_AUTH_ALLOWED)
|
||||
&& !getSession().getMetadata().isSubdomainAuthAllowed()) {
|
||||
throw new AcmeNotSupportedException("subdomain-auth");
|
||||
}
|
||||
|
||||
LOG.debug("preAuthorize {}", identifier);
|
||||
try (var conn = getSession().connect()) {
|
||||
var claims = new JSONBuilder();
|
||||
|
@ -280,6 +286,7 @@ public class Account extends AcmeJsonResource {
|
|||
* sure that they are valid according to the RFC. It is recommended to use
|
||||
* the {@code addContact()} methods below to add new contacts to the list.
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
|
||||
public List<URI> getContacts() {
|
||||
return editContacts;
|
||||
}
|
||||
|
|
|
@ -14,11 +14,15 @@
|
|||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.jose4j.jws.AlgorithmIdentifiers.*;
|
||||
import static org.shredzone.acme4j.toolbox.JoseUtils.macKeyAlgorithm;
|
||||
|
||||
import java.net.URI;
|
||||
import java.security.KeyPair;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
@ -55,6 +59,7 @@ import org.slf4j.LoggerFactory;
|
|||
*/
|
||||
public class AccountBuilder {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AccountBuilder.class);
|
||||
private static final Set<String> VALID_ALGORITHMS = Set.of(HMAC_SHA256, HMAC_SHA384, HMAC_SHA512);
|
||||
|
||||
private final List<URI> contacts = new ArrayList<>();
|
||||
private @Nullable Boolean termsOfServiceAgreed;
|
||||
|
@ -62,6 +67,7 @@ public class AccountBuilder {
|
|||
private @Nullable String keyIdentifier;
|
||||
private @Nullable KeyPair keyPair;
|
||||
private @Nullable SecretKey macKey;
|
||||
private @Nullable String macAlgorithm;
|
||||
|
||||
/**
|
||||
* Add a contact URI to the list of contacts.
|
||||
|
@ -210,6 +216,25 @@ public class AccountBuilder {
|
|||
return withKeyIdentifier(kid, new SecretKeySpec(encodedKey, "HMAC"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the MAC key algorithm that is provided by the CA. To be used in combination
|
||||
* with key identifier. By default, the algorithm is deduced from the size of the
|
||||
* MAC key. If a different size is needed, it can be set using this method.
|
||||
*
|
||||
* @param macAlgorithm
|
||||
* the algorithm to be set in the {@code alg} field, e.g. {@code "HS512"}.
|
||||
* @return itself
|
||||
* @since 3.1.0
|
||||
*/
|
||||
public AccountBuilder withMacAlgorithm(String macAlgorithm) {
|
||||
var algorithm = requireNonNull(macAlgorithm, "macAlgorithm");
|
||||
if (!VALID_ALGORITHMS.contains(algorithm)) {
|
||||
throw new IllegalArgumentException("Invalid MAC algorithm: " + macAlgorithm);
|
||||
}
|
||||
this.macAlgorithm = algorithm;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new account.
|
||||
* <p>
|
||||
|
@ -254,9 +279,13 @@ public class AccountBuilder {
|
|||
if (termsOfServiceAgreed != null) {
|
||||
claims.put("termsOfServiceAgreed", termsOfServiceAgreed);
|
||||
}
|
||||
if (keyIdentifier != null) {
|
||||
if (keyIdentifier != null && macKey != null) {
|
||||
var algorithm = Optional.ofNullable(macAlgorithm)
|
||||
.or(session.provider()::getProposedEabMacAlgorithm)
|
||||
// FIXME: Cannot use a Supplier here due to a Spotbugs false positive "null pointer dereference"
|
||||
.orElse(macKeyAlgorithm(macKey));
|
||||
claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding(
|
||||
keyIdentifier, keyPair.getPublic(), macKey, resourceUrl));
|
||||
keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl));
|
||||
}
|
||||
if (onlyExisting != null) {
|
||||
claims.put("onlyReturnExisting", onlyExisting);
|
||||
|
|
|
@ -13,13 +13,15 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
@ -30,10 +32,12 @@ import org.slf4j.LoggerFactory;
|
|||
* fetching it from the server if necessary.
|
||||
*/
|
||||
public abstract class AcmeJsonResource extends AcmeResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -5060364275766082345L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AcmeJsonResource.class);
|
||||
|
||||
private @Nullable JSON data = null;
|
||||
private @Nullable Instant retryAfter = null;
|
||||
|
||||
/**
|
||||
* Create a new {@link AcmeJsonResource}.
|
||||
|
@ -50,7 +54,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
|
|||
/**
|
||||
* Returns the JSON representation of the resource data.
|
||||
* <p>
|
||||
* If there is no data, {@link #update()} is invoked to fetch it from the server.
|
||||
* If there is no data, {@link #fetch()} is invoked to fetch it from the server.
|
||||
* <p>
|
||||
* This method can be used to read proprietary data from the resources.
|
||||
*
|
||||
|
@ -62,15 +66,12 @@ public abstract class AcmeJsonResource extends AcmeResource {
|
|||
public JSON getJSON() {
|
||||
if (data == null) {
|
||||
try {
|
||||
update();
|
||||
} catch (AcmeRetryAfterException ex) {
|
||||
// ignore... The object was still updated.
|
||||
LOG.debug("Retry-After", ex);
|
||||
fetch();
|
||||
} catch (AcmeException ex) {
|
||||
throw new AcmeLazyLoadingException(this, ex);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
return Objects.requireNonNull(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -88,7 +89,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
|
|||
* Checks if this resource is valid.
|
||||
*
|
||||
* @return {@code true} if the resource state has been loaded from the server. If
|
||||
* {@code false}, {@link #getJSON()} would implicitly call {@link #update()}
|
||||
* {@code false}, {@link #getJSON()} would implicitly call {@link #fetch()}
|
||||
* to fetch the current state from the server.
|
||||
*/
|
||||
protected boolean isValid() {
|
||||
|
@ -96,7 +97,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
|
|||
}
|
||||
|
||||
/**
|
||||
* Invalidates the state of this resource. Enforces an {@link #update()} when
|
||||
* Invalidates the state of this resource. Enforces a {@link #fetch()} when
|
||||
* {@link #getJSON()} is invoked.
|
||||
* <p>
|
||||
* Subclasses can override this method to purge internal caches that are based on the
|
||||
|
@ -104,28 +105,53 @@ public abstract class AcmeJsonResource extends AcmeResource {
|
|||
*/
|
||||
protected void invalidate() {
|
||||
data = null;
|
||||
retryAfter = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates this resource, by fetching the current resource data from the server.
|
||||
*
|
||||
* @return An {@link Optional} estimation when the resource status will change. If you
|
||||
* are polling for the resource to complete, you should wait for the given instant
|
||||
* before trying again. Empty if the server did not return a "Retry-After" header.
|
||||
* @throws AcmeException
|
||||
* if the resource could not be fetched.
|
||||
* @throws AcmeRetryAfterException
|
||||
* the resource is still being processed, and the server returned an
|
||||
* estimated date when the process will be completed. If you are polling
|
||||
* for the resource to complete, you should wait for the date given in
|
||||
* {@link AcmeRetryAfterException#getRetryAfter()}. Note that the status
|
||||
* of the resource is updated even if this exception was thrown.
|
||||
* if the resource could not be fetched.
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public void update() throws AcmeException {
|
||||
public Optional<Instant> fetch() throws AcmeException {
|
||||
var resourceType = getClass().getSimpleName();
|
||||
LOG.debug("update {}", resourceType);
|
||||
try (var conn = getSession().connect()) {
|
||||
conn.sendSignedPostAsGetRequest(getLocation(), getLogin());
|
||||
setJSON(conn.readJsonResponse());
|
||||
conn.handleRetryAfter(resourceType + " is not completed yet");
|
||||
var retryAfterOpt = conn.getRetryAfter();
|
||||
retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant));
|
||||
setRetryAfter(retryAfterOpt.orElse(null));
|
||||
return retryAfterOpt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a Retry-After instant.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*/
|
||||
protected void setRetryAfter(@Nullable Instant retryAfter) {
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an estimation when the resource status will change. If you are polling for
|
||||
* the resource to complete, you should wait for the given instant before trying
|
||||
* a status refresh.
|
||||
* <p>
|
||||
* This instant was sent with the Retry-After header at the last update.
|
||||
*
|
||||
* @return Retry-after {@link Instant}, or empty if there was no such header.
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public Optional<Instant> getRetryAfter() {
|
||||
return Optional.ofNullable(retryAfter);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.net.URL;
|
||||
import java.util.Objects;
|
||||
|
@ -28,6 +29,7 @@ import edu.umd.cs.findbugs.annotations.Nullable;
|
|||
* using {@link #rebind(Login)}.
|
||||
*/
|
||||
public abstract class AcmeResource implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -7930580802257379731L;
|
||||
|
||||
private transient @Nullable Login login;
|
||||
|
@ -87,4 +89,9 @@ public abstract class AcmeResource implements Serializable {
|
|||
return location;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final void finalize() {
|
||||
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -15,8 +15,11 @@ package org.shredzone.acme4j;
|
|||
|
||||
import static java.util.stream.Collectors.toUnmodifiableList;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
|
@ -32,7 +35,8 @@ import org.slf4j.LoggerFactory;
|
|||
/**
|
||||
* Represents an authorization request at the ACME server.
|
||||
*/
|
||||
public class Authorization extends AcmeJsonResource {
|
||||
public class Authorization extends AcmeJsonResource implements PollableResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -3116928998379417741L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Authorization.class);
|
||||
|
||||
|
@ -60,6 +64,7 @@ public class Authorization extends AcmeJsonResource {
|
|||
* {@link Status#INVALID}, {@link Status#DEACTIVATED}, {@link Status#EXPIRED},
|
||||
* {@link Status#REVOKED}.
|
||||
*/
|
||||
@Override
|
||||
public Status getStatus() {
|
||||
return getJSON().get("status").asStatus();
|
||||
}
|
||||
|
@ -83,6 +88,18 @@ public class Authorization extends AcmeJsonResource {
|
|||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if certificates for subdomains can be issued according to
|
||||
* RFC9444.
|
||||
*
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public boolean isSubdomainAuthAllowed() {
|
||||
return getJSON().get("subdomainAuthAllowed")
|
||||
.map(Value::asBoolean)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all challenges offered by the server, in no specific order.
|
||||
*/
|
||||
|
@ -139,6 +156,24 @@ public class Authorization extends AcmeJsonResource {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the authorization is completed.
|
||||
* <p>
|
||||
* Is is completed if it reaches either {@link Status#VALID} or
|
||||
* {@link Status#INVALID}.
|
||||
* <p>
|
||||
* This method is synchronous and blocks the current thread.
|
||||
*
|
||||
* @param timeout
|
||||
* Timeout until a terminal status must have been reached
|
||||
* @return Status that was reached
|
||||
* @since 3.4.0
|
||||
*/
|
||||
public Status waitForCompletion(Duration timeout)
|
||||
throws AcmeException, InterruptedException {
|
||||
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently deactivates the {@link Authorization}.
|
||||
*/
|
||||
|
|
|
@ -15,15 +15,18 @@ package org.shredzone.acme4j;
|
|||
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static java.util.stream.Collectors.toUnmodifiableList;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serial;
|
||||
import java.io.Writer;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.security.KeyPair;
|
||||
import java.security.Principal;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collection;
|
||||
|
@ -31,12 +34,7 @@ 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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.shredzone.acme4j.connector.Resource;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
|
||||
|
@ -54,6 +52,7 @@ import org.slf4j.LoggerFactory;
|
|||
* ordered.
|
||||
*/
|
||||
public class Certificate extends AcmeResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 7381527770159084201L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Certificate.class);
|
||||
|
||||
|
@ -130,9 +129,9 @@ public class Certificate extends AcmeResource {
|
|||
var login = getLogin();
|
||||
alternateCerts = getAlternates().stream()
|
||||
.map(login::bindCertificate)
|
||||
.collect(toUnmodifiableList());
|
||||
.collect(toList());
|
||||
}
|
||||
return alternateCerts;
|
||||
return unmodifiableList(alternateCerts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -189,35 +188,6 @@ 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.
|
||||
|
@ -233,8 +203,8 @@ public class Certificate extends AcmeResource {
|
|||
if (!url.endsWith("/")) {
|
||||
url += '/';
|
||||
}
|
||||
url += getCertID();
|
||||
return new URL(url);
|
||||
url += getRenewalUniqueIdentifier(getCertificate());
|
||||
return URI.create(url).toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException("Invalid RenewalInfo URL", ex);
|
||||
}
|
||||
|
@ -257,8 +227,10 @@ public class Certificate extends AcmeResource {
|
|||
* Reads the RenewalInfo for this certificate.
|
||||
*
|
||||
* @return The {@link RenewalInfo} of this certificate.
|
||||
* @throws AcmeNotSupportedException if the CA does not support renewal information.
|
||||
* @since 3.0.0
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
|
||||
public RenewalInfo getRenewalInfo() {
|
||||
if (renewalInfo == null) {
|
||||
renewalInfo = getRenewalInfoLocation()
|
||||
|
|
|
@ -13,17 +13,19 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.Collections.unmodifiableMap;
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
||||
|
||||
/**
|
||||
* Represents an identifier.
|
||||
|
@ -36,6 +38,7 @@ import org.shredzone.acme4j.toolbox.JSONBuilder;
|
|||
* @since 2.3
|
||||
*/
|
||||
public class Identifier implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -7777851842076362412L;
|
||||
|
||||
/**
|
||||
|
@ -50,11 +53,12 @@ public class Identifier implements Serializable {
|
|||
*/
|
||||
public static final String TYPE_IP = "ip";
|
||||
|
||||
private static final String KEY_TYPE = "type";
|
||||
private static final String KEY_VALUE = "value";
|
||||
static final String KEY_TYPE = "type";
|
||||
static final String KEY_VALUE = "value";
|
||||
static final String KEY_ANCESTOR_DOMAIN = "ancestorDomain";
|
||||
static final String KEY_SUBDOMAIN_AUTH_ALLOWED = "subdomainAuthAllowed";
|
||||
|
||||
private final String type;
|
||||
private final String value;
|
||||
private final Map<String, Object> content = new TreeMap<>();
|
||||
|
||||
/**
|
||||
* Creates a new {@link Identifier}.
|
||||
|
@ -71,8 +75,8 @@ public class Identifier implements Serializable {
|
|||
* Identifier value
|
||||
*/
|
||||
public Identifier(String type, String value) {
|
||||
this.type = requireNonNull(type, KEY_TYPE);
|
||||
this.value = requireNonNull(value, KEY_VALUE);
|
||||
content.put(KEY_TYPE, requireNonNull(type, KEY_TYPE));
|
||||
content.put(KEY_VALUE, requireNonNull(value, KEY_VALUE));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -82,7 +86,20 @@ public class Identifier implements Serializable {
|
|||
* {@link JSON} containing the identifier data
|
||||
*/
|
||||
public Identifier(JSON json) {
|
||||
this(json.get(KEY_TYPE).asString(), json.get(KEY_VALUE).asString());
|
||||
if (!json.contains(KEY_TYPE)) {
|
||||
throw new AcmeProtocolException("Required key " + KEY_TYPE + " is missing");
|
||||
}
|
||||
if (!json.contains(KEY_VALUE)) {
|
||||
throw new AcmeProtocolException("Required key " + KEY_VALUE + " is missing");
|
||||
}
|
||||
content.putAll(json.toMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a copy of the given Identifier.
|
||||
*/
|
||||
private Identifier(Identifier identifier) {
|
||||
content.putAll(identifier.content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -123,18 +140,50 @@ public class Identifier implements Serializable {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an ancestor domain, as required in RFC-9444.
|
||||
*
|
||||
* @param domain
|
||||
* The ancestor domain to be set. Unicode domains are automatically ASCII
|
||||
* encoded.
|
||||
* @return An {@link Identifier} that contains the ancestor domain.
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public Identifier withAncestorDomain(String domain) {
|
||||
expectType(TYPE_DNS);
|
||||
|
||||
var result = new Identifier(this);
|
||||
result.content.put(KEY_ANCESTOR_DOMAIN, toAce(domain));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the permission to authorize subdomains of this domain, as required in
|
||||
* RFC-9444.
|
||||
*
|
||||
* @return An {@link Identifier} that allows subdomain auths.
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public Identifier allowSubdomainAuth() {
|
||||
expectType(TYPE_DNS);
|
||||
|
||||
var result = new Identifier(this);
|
||||
result.content.put(KEY_SUBDOMAIN_AUTH_ALLOWED, true);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier type.
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
return content.get(KEY_TYPE).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier value.
|
||||
*/
|
||||
public String getValue() {
|
||||
return value;
|
||||
return content.get(KEY_VALUE).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -145,10 +194,8 @@ public class Identifier implements Serializable {
|
|||
* if this is not a DNS identifier.
|
||||
*/
|
||||
public String getDomain() {
|
||||
if (!TYPE_DNS.equals(type)) {
|
||||
throw new AcmeProtocolException("expected 'dns' identifier, but found '" + type + "'");
|
||||
}
|
||||
return value;
|
||||
expectType(TYPE_DNS);
|
||||
return getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -159,11 +206,9 @@ public class Identifier implements Serializable {
|
|||
* if this is not a DNS identifier.
|
||||
*/
|
||||
public InetAddress getIP() {
|
||||
if (!TYPE_IP.equals(type)) {
|
||||
throw new AcmeProtocolException("expected 'ip' identifier, but found '" + type + "'");
|
||||
}
|
||||
expectType(TYPE_IP);
|
||||
try {
|
||||
return InetAddress.getByName(value);
|
||||
return InetAddress.getByName(getValue());
|
||||
} catch (UnknownHostException ex) {
|
||||
throw new AcmeProtocolException("bad ip identifier value", ex);
|
||||
}
|
||||
|
@ -173,27 +218,47 @@ public class Identifier implements Serializable {
|
|||
* Returns the identifier as JSON map.
|
||||
*/
|
||||
public Map<String, Object> toMap() {
|
||||
return new JSONBuilder().put(KEY_TYPE, type).put(KEY_VALUE, value).toMap();
|
||||
return unmodifiableMap(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure this identifier is of the given type.
|
||||
*
|
||||
* @param type
|
||||
* Expected type
|
||||
* @throws AcmeProtocolException
|
||||
* if this identifier is of a different type
|
||||
*/
|
||||
private void expectType(String type) {
|
||||
if (!type.equals(getType())) {
|
||||
throw new AcmeProtocolException("expected '" + type + "' identifier, but found '" + getType() + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return type + "=" + value;
|
||||
if (content.size() == 2) {
|
||||
return getType() + '=' + getValue();
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (!(obj instanceof Identifier)) {
|
||||
if (!(obj instanceof Identifier i)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var i = (Identifier) obj;
|
||||
return type.equals(i.type) && value.equals(i.value);
|
||||
return content.equals(i.content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return type.hashCode() ^ value.hashCode();
|
||||
return content.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final void finalize() {
|
||||
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,12 +14,18 @@
|
|||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.security.KeyPair;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Objects;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.shredzone.acme4j.challenge.Challenge;
|
||||
import org.shredzone.acme4j.connector.Resource;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
|
@ -70,6 +76,7 @@ public class Login {
|
|||
/**
|
||||
* Gets the {@link Session} that is used.
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
|
||||
public Session getSession() {
|
||||
return session;
|
||||
}
|
||||
|
@ -93,6 +100,7 @@ public class Login {
|
|||
*
|
||||
* @return {@link Account} bound to the login
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
@ -145,6 +153,28 @@ public class Login {
|
|||
return new RenewalInfo(this, requireNonNull(location, "location"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance of an existing {@link RenewalInfo} and binds it to this
|
||||
* login.
|
||||
*
|
||||
* @param certificate
|
||||
* {@link X509Certificate} to get the {@link RenewalInfo} for
|
||||
* @return {@link RenewalInfo} bound to the login
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public RenewalInfo bindRenewalInfo(X509Certificate certificate) throws AcmeException {
|
||||
try {
|
||||
var url = getSession().resourceUrl(Resource.RENEWAL_INFO).toExternalForm();
|
||||
if (!url.endsWith("/")) {
|
||||
url += '/';
|
||||
}
|
||||
url += getRenewalUniqueIdentifier(certificate);
|
||||
return bindRenewalInfo(URI.create(url).toURL());
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException("Invalid RenewalInfo URL", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
|
@ -20,6 +20,7 @@ import java.net.URL;
|
|||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
|
@ -131,6 +132,78 @@ public class Metadata {
|
|||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the CA supports the profile feature.
|
||||
*
|
||||
* @since 3.5.0
|
||||
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
|
||||
* may be changed or removed without notice to reflect future changes to the draft.
|
||||
* SemVer rules do not apply here.
|
||||
*/
|
||||
public boolean isProfileAllowed() {
|
||||
return meta.get("profiles").isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the CA supports the requested profile.
|
||||
* <p>
|
||||
* Also returns {@code false} if profiles are not allowed in general.
|
||||
*
|
||||
* @since 3.5.0
|
||||
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
|
||||
* may be changed or removed without notice to reflect future changes to the draft.
|
||||
* SemVer rules do not apply here.
|
||||
*/
|
||||
public boolean isProfileAllowed(String profile) {
|
||||
return getProfiles().contains(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all profiles supported by the CA. May be empty if the CA does not support
|
||||
* profiles.
|
||||
*
|
||||
* @since 3.5.0
|
||||
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
|
||||
* may be changed or removed without notice to reflect future changes to the draft.
|
||||
* SemVer rules do not apply here.
|
||||
*/
|
||||
public Set<String> getProfiles() {
|
||||
return meta.get("profiles")
|
||||
.optional()
|
||||
.map(Value::asObject)
|
||||
.orElseGet(JSON::empty)
|
||||
.keySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a description of the requested profile. This can be a human-readable string
|
||||
* or a URL linking to a documentation.
|
||||
* <p>
|
||||
* Empty if the profile is not allowed.
|
||||
*
|
||||
* @since 3.5.0
|
||||
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
|
||||
* may be changed or removed without notice to reflect future changes to the draft.
|
||||
* SemVer rules do not apply here.
|
||||
*/
|
||||
public Optional<String> getProfileDescription(String profile) {
|
||||
return meta.get("profiles").optional()
|
||||
.map(Value::asObject)
|
||||
.orElseGet(JSON::empty)
|
||||
.get(profile)
|
||||
.optional()
|
||||
.map(Value::asString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the CA supports subdomain auth according to RFC9444.
|
||||
*
|
||||
* @since 3.3.0
|
||||
*/
|
||||
public boolean isSubdomainAuthAllowed() {
|
||||
return meta.get("subdomainAuthAllowed").map(Value::asBoolean).orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JSON representation of the metadata. This is useful for reading
|
||||
* proprietary metadata properties.
|
||||
|
|
|
@ -13,18 +13,22 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.stream.Collectors.toUnmodifiableList;
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
import java.security.KeyPair;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
|
||||
|
@ -38,12 +42,12 @@ import org.slf4j.LoggerFactory;
|
|||
/**
|
||||
* A representation of a certificate order at the CA.
|
||||
*/
|
||||
public class Order extends AcmeJsonResource {
|
||||
public class Order extends AcmeJsonResource implements PollableResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 5435808648658292177L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Order.class);
|
||||
|
||||
private transient @Nullable Certificate certificate = null;
|
||||
private transient @Nullable Certificate autoRenewalCertificate = null;
|
||||
private transient @Nullable List<Authorization> authorizations = null;
|
||||
|
||||
protected Order(Login login, URL location) {
|
||||
|
@ -57,6 +61,7 @@ public class Order extends AcmeJsonResource {
|
|||
* {@link Status#PROCESSING}, {@link Status#VALID}, {@link Status#INVALID}.
|
||||
* If the server supports STAR, another possible value is {@link Status#CANCELED}.
|
||||
*/
|
||||
@Override
|
||||
public Status getStatus() {
|
||||
return getJSON().get("status").asStatus();
|
||||
}
|
||||
|
@ -85,7 +90,7 @@ public class Order extends AcmeJsonResource {
|
|||
.asArray()
|
||||
.stream()
|
||||
.map(Value::asIdentifier)
|
||||
.collect(toUnmodifiableList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -114,9 +119,9 @@ public class Order extends AcmeJsonResource {
|
|||
.stream()
|
||||
.map(Value::asURL)
|
||||
.map(login::bindAuthorization)
|
||||
.collect(toUnmodifiableList());
|
||||
.collect(toList());
|
||||
}
|
||||
return authorizations;
|
||||
return unmodifiableList(authorizations);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,9 +140,12 @@ public class Order extends AcmeJsonResource {
|
|||
* if the order is not ready yet. You must finalize the order first, and wait
|
||||
* for the status to become {@link Status#VALID}.
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
|
||||
public Certificate getCertificate() {
|
||||
if (certificate == null) {
|
||||
certificate = getJSON().get("certificate")
|
||||
certificate = getJSON().get("star-certificate")
|
||||
.optional()
|
||||
.or(() -> getJSON().get("certificate").optional())
|
||||
.map(Value::asURL)
|
||||
.map(getLogin()::bindCertificate)
|
||||
.orElseThrow(() -> new IllegalStateException("Order is not completed"));
|
||||
|
@ -146,23 +154,13 @@ public class Order extends AcmeJsonResource {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the STAR extension's {@link Certificate} if it is available.
|
||||
* Returns whether this is a STAR certificate ({@code true}) or a standard certificate
|
||||
* ({@code false}).
|
||||
*
|
||||
* @since 2.6
|
||||
* @throws IllegalStateException
|
||||
* if the order is not ready yet. You must finalize the order first, and wait
|
||||
* for the status to become {@link Status#VALID}. It is also thrown if the
|
||||
* order has been {@link Status#CANCELED}.
|
||||
* @since 3.5.0
|
||||
*/
|
||||
public Certificate getAutoRenewalCertificate() {
|
||||
if (autoRenewalCertificate == null) {
|
||||
autoRenewalCertificate = getJSON().get("star-certificate")
|
||||
.optional()
|
||||
.map(Value::asURL)
|
||||
.map(getLogin()::bindCertificate)
|
||||
.orElseThrow(() -> new IllegalStateException("Order is in an invalid state"));
|
||||
}
|
||||
return autoRenewalCertificate;
|
||||
public boolean isAutoRenewalCertificate() {
|
||||
return getJSON().contains("star-certificate");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -181,6 +179,8 @@ public class Order extends AcmeJsonResource {
|
|||
* @see #execute(KeyPair, Consumer)
|
||||
* @see #execute(PKCS10CertificationRequest)
|
||||
* @see #execute(byte[])
|
||||
* @see #waitUntilReady(Duration)
|
||||
* @see #waitForCompletion(Duration)
|
||||
* @since 3.0.0
|
||||
*/
|
||||
public void execute(KeyPair domainKeyPair) throws AcmeException {
|
||||
|
@ -203,6 +203,8 @@ public class Order extends AcmeJsonResource {
|
|||
* @see #execute(KeyPair)
|
||||
* @see #execute(PKCS10CertificationRequest)
|
||||
* @see #execute(byte[])
|
||||
* @see #waitUntilReady(Duration)
|
||||
* @see #waitForCompletion(Duration)
|
||||
* @since 3.0.0
|
||||
*/
|
||||
public void execute(KeyPair domainKeyPair, Consumer<CSRBuilder> builderConsumer) throws AcmeException {
|
||||
|
@ -230,6 +232,8 @@ public class Order extends AcmeJsonResource {
|
|||
* @see #execute(KeyPair)
|
||||
* @see #execute(KeyPair, Consumer)
|
||||
* @see #execute(byte[])
|
||||
* @see #waitUntilReady(Duration)
|
||||
* @see #waitForCompletion(Duration)
|
||||
* @since 3.0.0
|
||||
*/
|
||||
public void execute(PKCS10CertificationRequest csr) throws AcmeException {
|
||||
|
@ -251,6 +255,8 @@ public class Order extends AcmeJsonResource {
|
|||
* @param csr
|
||||
* Binary representation of a CSR containing the parameters for the
|
||||
* certificate being requested, in DER format
|
||||
* @see #waitUntilReady(Duration)
|
||||
* @see #waitForCompletion(Duration)
|
||||
*/
|
||||
public void execute(byte[] csr) throws AcmeException {
|
||||
LOG.debug("finalize");
|
||||
|
@ -263,6 +269,43 @@ public class Order extends AcmeJsonResource {
|
|||
invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the order is ready for finalization.
|
||||
* <p>
|
||||
* Is is ready if it reaches {@link Status#READY}. The method will also return if the
|
||||
* order already has another terminal state, which is either {@link Status#VALID} or
|
||||
* {@link Status#INVALID}.
|
||||
* <p>
|
||||
* This method is synchronous and blocks the current thread.
|
||||
*
|
||||
* @param timeout
|
||||
* Timeout until a terminal status must have been reached
|
||||
* @return Status that was reached
|
||||
* @since 3.4.0
|
||||
*/
|
||||
public Status waitUntilReady(Duration timeout)
|
||||
throws AcmeException, InterruptedException {
|
||||
return waitForStatus(EnumSet.of(Status.READY, Status.VALID, Status.INVALID), timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the order finalization is completed.
|
||||
* <p>
|
||||
* Is is completed if it reaches either {@link Status#VALID} or
|
||||
* {@link Status#INVALID}.
|
||||
* <p>
|
||||
* This method is synchronous and blocks the current thread.
|
||||
*
|
||||
* @param timeout
|
||||
* Timeout until a terminal status must have been reached
|
||||
* @return Status that was reached
|
||||
* @since 3.4.0
|
||||
*/
|
||||
public Status waitForCompletion(Duration timeout)
|
||||
throws AcmeException, InterruptedException {
|
||||
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this order is auto-renewing, according to the ACME STAR specifications.
|
||||
*
|
||||
|
@ -371,11 +414,20 @@ public class Order extends AcmeJsonResource {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the selected profile.
|
||||
*
|
||||
* @since 3.5.0
|
||||
* @throws AcmeNotSupportedException if profile is not supported
|
||||
*/
|
||||
public String getProfile() {
|
||||
return getJSON().getFeature("profile").asString();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidate() {
|
||||
super.invalidate();
|
||||
certificate = null;
|
||||
autoRenewalCertificate = null;
|
||||
authorizations = null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,11 +15,14 @@ package org.shredzone.acme4j;
|
|||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
|
@ -44,12 +47,14 @@ public class OrderBuilder {
|
|||
private final Set<Identifier> identifierSet = new LinkedHashSet<>();
|
||||
private @Nullable Instant notBefore;
|
||||
private @Nullable Instant notAfter;
|
||||
private @Nullable String replaces;
|
||||
private boolean autoRenewal;
|
||||
private @Nullable Instant autoRenewalStart;
|
||||
private @Nullable Instant autoRenewalEnd;
|
||||
private @Nullable Duration autoRenewalLifetime;
|
||||
private @Nullable Duration autoRenewalLifetimeAdjust;
|
||||
private boolean autoRenewalGet;
|
||||
private @Nullable String profile;
|
||||
|
||||
/**
|
||||
* Create a new {@link OrderBuilder}.
|
||||
|
@ -265,6 +270,74 @@ public class OrderBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the CA of the desired profile of the ordered certificate.
|
||||
* <p>
|
||||
* Optional, only supported if the CA supports profiles. However, in this case the
|
||||
* client <em>may</em> include this field.
|
||||
*
|
||||
* @param profile
|
||||
* Identifier of the desired profile
|
||||
* @return itself
|
||||
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
|
||||
* may be changed or removed without notice to reflect future changes to the draft.
|
||||
* SemVer rules do not apply here.
|
||||
* @since 3.5.0
|
||||
*/
|
||||
public OrderBuilder profile(String profile) {
|
||||
this.profile = Objects.requireNonNull(profile);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the CA that the ordered certificate will replace a previously issued
|
||||
* certificate. The certificate is identified by its ARI unique identifier.
|
||||
* <p>
|
||||
* Optional, only supported if the CA provides renewal information. However, in this
|
||||
* case the client <em>should</em> include this field.
|
||||
*
|
||||
* @param uniqueId
|
||||
* Certificate's renewal unique identifier.
|
||||
* @return itself
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public OrderBuilder replaces(String uniqueId) {
|
||||
this.replaces = Objects.requireNonNull(uniqueId);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the CA that the ordered certificate will replace a previously issued
|
||||
* certificate.
|
||||
* <p>
|
||||
* Optional, only supported if the CA provides renewal information. However, in this
|
||||
* case the client <em>should</em> include this field.
|
||||
*
|
||||
* @param certificate
|
||||
* Certificate to be replaced
|
||||
* @return itself
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public OrderBuilder replaces(X509Certificate certificate) {
|
||||
return replaces(getRenewalUniqueIdentifier(certificate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the CA that the ordered certificate will replace a previously issued
|
||||
* certificate.
|
||||
* <p>
|
||||
* Optional, only supported if the CA provides renewal information. However, in this
|
||||
* case the client <em>should</em> include this field.
|
||||
*
|
||||
* @param certificate
|
||||
* Certificate to be replaced
|
||||
* @return itself
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public OrderBuilder replaces(Certificate certificate) {
|
||||
return replaces(certificate.getCertificate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new order to the server, and returns an {@link Order} object.
|
||||
*
|
||||
|
@ -281,6 +354,29 @@ public class OrderBuilder {
|
|||
throw new AcmeNotSupportedException("auto-renewal");
|
||||
}
|
||||
|
||||
if (autoRenewalGet && !session.getMetadata().isAutoRenewalGetAllowed()) {
|
||||
throw new AcmeNotSupportedException("auto-renewal-get");
|
||||
}
|
||||
|
||||
if (replaces != null && session.resourceUrlOptional(Resource.RENEWAL_INFO).isEmpty()) {
|
||||
throw new AcmeNotSupportedException("renewal-information");
|
||||
}
|
||||
|
||||
if (profile != null && !session.getMetadata().isProfileAllowed()) {
|
||||
throw new AcmeNotSupportedException("profile");
|
||||
}
|
||||
|
||||
if (profile != null && !session.getMetadata().isProfileAllowed(profile)) {
|
||||
throw new AcmeNotSupportedException("profile: " + profile);
|
||||
}
|
||||
|
||||
var hasAncestorDomain = identifierSet.stream()
|
||||
.filter(id -> Identifier.TYPE_DNS.equals(id.getType()))
|
||||
.anyMatch(id -> id.toMap().containsKey(Identifier.KEY_ANCESTOR_DOMAIN));
|
||||
if (hasAncestorDomain && !login.getSession().getMetadata().isSubdomainAuthAllowed()) {
|
||||
throw new AcmeNotSupportedException("ancestor-domain");
|
||||
}
|
||||
|
||||
LOG.debug("create");
|
||||
try (var conn = session.connect()) {
|
||||
var claims = new JSONBuilder();
|
||||
|
@ -312,6 +408,14 @@ public class OrderBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
if (replaces != null) {
|
||||
claims.put("replaces", replaces);
|
||||
}
|
||||
|
||||
if (profile != null) {
|
||||
claims.put("profile", profile);
|
||||
}
|
||||
|
||||
conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, login);
|
||||
|
||||
var order = new Order(login, conn.getLocation());
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
import static java.time.Instant.now;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
|
||||
/**
|
||||
* Marks an ACME Resource with a pollable status.
|
||||
* <p>
|
||||
* The resource provides a status, and a method for updating the internal cache to read
|
||||
* the current status from the server.
|
||||
*
|
||||
* @since 3.4.0
|
||||
*/
|
||||
public interface PollableResource {
|
||||
|
||||
/**
|
||||
* Default delay between status polls if there is no Retry-After header.
|
||||
*/
|
||||
Duration DEFAULT_RETRY_AFTER = Duration.ofSeconds(3L);
|
||||
|
||||
/**
|
||||
* Returns the current status of the resource.
|
||||
*/
|
||||
Status getStatus();
|
||||
|
||||
/**
|
||||
* Fetches the current status from the server.
|
||||
*
|
||||
* @return Retry-After time, if given by the CA, otherwise empty.
|
||||
*/
|
||||
Optional<Instant> fetch() throws AcmeException;
|
||||
|
||||
/**
|
||||
* Waits until a terminal status has been reached, by polling until one of the given
|
||||
* status or the given timeout has been reached. This call honors the Retry-After
|
||||
* header if set by the CA.
|
||||
* <p>
|
||||
* This method is synchronous and blocks the current thread.
|
||||
* <p>
|
||||
* If the resource is already in a terminal status, the method returns immediately.
|
||||
*
|
||||
* @param statusSet
|
||||
* Set of {@link Status} that are accepted as terminal
|
||||
* @param timeout
|
||||
* Timeout until a terminal status must have been reached
|
||||
* @return Status that was reached
|
||||
*/
|
||||
default Status waitForStatus(Set<Status> statusSet, Duration timeout)
|
||||
throws AcmeException, InterruptedException {
|
||||
Objects.requireNonNull(timeout, "timeout");
|
||||
Objects.requireNonNull(statusSet, "statusSet");
|
||||
if (statusSet.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one Status is required");
|
||||
}
|
||||
|
||||
var currentStatus = getStatus();
|
||||
if (statusSet.contains(currentStatus)) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
var timebox = now().plus(timeout);
|
||||
Instant now;
|
||||
|
||||
while ((now = now()).isBefore(timebox)) {
|
||||
// Poll status and get the time of the next poll
|
||||
var retryAfter = fetch()
|
||||
.orElse(now.plus(DEFAULT_RETRY_AFTER));
|
||||
|
||||
currentStatus = getStatus();
|
||||
if (statusSet.contains(currentStatus)) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// Preemptively end the loop if the next iteration would be after timebox
|
||||
if (retryAfter.isAfter(timebox)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait until retryAfter is reached
|
||||
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
|
||||
}
|
||||
|
||||
throw new AcmeException("Timeout has been reached");
|
||||
}
|
||||
|
||||
}
|
|
@ -13,8 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.stream.Collectors.toUnmodifiableList;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
@ -33,6 +32,7 @@ import org.shredzone.acme4j.toolbox.JSON.Value;
|
|||
* @see <a href="https://tools.ietf.org/html/rfc7807">RFC 7807</a>
|
||||
*/
|
||||
public class Problem implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -8418248862966754214L;
|
||||
|
||||
private final URL baseUrl;
|
||||
|
@ -122,7 +122,7 @@ public class Problem implements Serializable {
|
|||
.asArray()
|
||||
.stream()
|
||||
.map(o -> o.asProblem(baseUrl))
|
||||
.collect(toUnmodifiableList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,8 +35,6 @@ import org.slf4j.LoggerFactory;
|
|||
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);
|
||||
}
|
||||
|
@ -65,15 +63,6 @@ public class RenewalInfo extends AcmeJsonResource {
|
|||
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.
|
||||
|
@ -172,16 +161,6 @@ public class RenewalInfo extends AcmeJsonResource {
|
|||
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.
|
||||
*/
|
||||
|
@ -191,4 +170,17 @@ public class RenewalInfo extends AcmeJsonResource {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Instant> fetch() throws AcmeException {
|
||||
LOG.debug("update RenewalInfo");
|
||||
try (Connection conn = getSession().connect()) {
|
||||
conn.sendRequest(getLocation(), getSession(), null);
|
||||
setJSON(conn.readJsonResponse());
|
||||
var retryAfterOpt = conn.getRetryAfter();
|
||||
retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant));
|
||||
setRetryAfter(retryAfterOpt.orElse(null));
|
||||
return retryAfterOpt;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||
import java.util.stream.StreamSupport;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.shredzone.acme4j.connector.Connection;
|
||||
import org.shredzone.acme4j.connector.NetworkSettings;
|
||||
import org.shredzone.acme4j.connector.Resource;
|
||||
|
@ -200,6 +201,7 @@ public class Session {
|
|||
* @return {@link NetworkSettings}
|
||||
* @since 2.8
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
|
||||
public NetworkSettings networkSettings() {
|
||||
return networkSettings;
|
||||
}
|
||||
|
@ -368,4 +370,9 @@ public class Session {
|
|||
resourceMap.set(map);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final void finalize() {
|
||||
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
package org.shredzone.acme4j;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* An enumeration of status codes of challenges and authorizations.
|
||||
|
@ -32,7 +33,7 @@ public enum Status {
|
|||
|
||||
/**
|
||||
* The server is processing the resource. The client should invoke
|
||||
* {@link AcmeJsonResource#update()} and re-check the status.
|
||||
* {@link AcmeJsonResource#fetch()} and re-check the status.
|
||||
*/
|
||||
PROCESSING,
|
||||
|
||||
|
@ -84,7 +85,7 @@ public enum Status {
|
|||
* no match
|
||||
*/
|
||||
public static Status parse(String str) {
|
||||
var check = str.toUpperCase();
|
||||
var check = str.toUpperCase(Locale.ENGLISH);
|
||||
return Arrays.stream(values())
|
||||
.filter(s -> s.name().equals(check))
|
||||
.findFirst()
|
||||
|
|
|
@ -13,11 +13,15 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.challenge;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.shredzone.acme4j.AcmeJsonResource;
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.PollableResource;
|
||||
import org.shredzone.acme4j.Problem;
|
||||
import org.shredzone.acme4j.Status;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
|
@ -37,7 +41,8 @@ import org.slf4j.LoggerFactory;
|
|||
* own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all
|
||||
* required data to the challenge response.
|
||||
*/
|
||||
public class Challenge extends AcmeJsonResource {
|
||||
public class Challenge extends AcmeJsonResource implements PollableResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 2338794776848388099L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Challenge.class);
|
||||
|
||||
|
@ -76,6 +81,7 @@ public class Challenge extends AcmeJsonResource {
|
|||
* A challenge is only completed when it reaches either status {@link Status#VALID} or
|
||||
* {@link Status#INVALID}.
|
||||
*/
|
||||
@Override
|
||||
public Status getStatus() {
|
||||
return getJSON().get(KEY_STATUS).asStatus();
|
||||
}
|
||||
|
@ -155,6 +161,8 @@ public class Challenge extends AcmeJsonResource {
|
|||
* If this method is invoked a second time, the ACME server is requested to retry the
|
||||
* validation. This can be useful if the client state has changed, for example after a
|
||||
* firewall rule has been updated.
|
||||
*
|
||||
* @see #waitForCompletion(Duration)
|
||||
*/
|
||||
public void trigger() throws AcmeException {
|
||||
LOG.debug("trigger");
|
||||
|
@ -167,4 +175,22 @@ public class Challenge extends AcmeJsonResource {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the challenge is completed.
|
||||
* <p>
|
||||
* Is is completed if it reaches either {@link Status#VALID} or
|
||||
* {@link Status#INVALID}.
|
||||
* <p>
|
||||
* This method is synchronous and blocks the current thread.
|
||||
*
|
||||
* @param timeout
|
||||
* Timeout until a terminal status must have been reached
|
||||
* @return Status that was reached
|
||||
* @since 3.4.0
|
||||
*/
|
||||
public Status waitForCompletion(Duration timeout)
|
||||
throws AcmeException, InterruptedException {
|
||||
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ package org.shredzone.acme4j.challenge;
|
|||
import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
import org.shredzone.acme4j.Identifier;
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
|
@ -25,6 +27,7 @@ import org.shredzone.acme4j.toolbox.JSON;
|
|||
* validation. See the acme4j documentation for a detailed explanation.
|
||||
*/
|
||||
public class Dns01Challenge extends TokenChallenge {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 6964687027713533075L;
|
||||
|
||||
/**
|
||||
|
@ -37,6 +40,18 @@ public class Dns01Challenge extends TokenChallenge {
|
|||
*/
|
||||
public static final String RECORD_NAME_PREFIX = "_acme-challenge";
|
||||
|
||||
/**
|
||||
* Creates a new generic {@link Dns01Challenge} object.
|
||||
*
|
||||
* @param login
|
||||
* {@link Login} the resource is bound with
|
||||
* @param data
|
||||
* {@link JSON} challenge data
|
||||
*/
|
||||
public Dns01Challenge(Login login, JSON data) {
|
||||
super(login, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
|
||||
* record.
|
||||
|
@ -45,10 +60,10 @@ public class Dns01Challenge extends TokenChallenge {
|
|||
* Domain {@link Identifier} of the domain to be validated
|
||||
* @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note
|
||||
* the trailing full stop character).
|
||||
* @since 2.14
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public static String toRRName(Identifier identifier) {
|
||||
return toRRName(identifier.getDomain());
|
||||
public String getRRName(Identifier identifier) {
|
||||
return getRRName(identifier.getDomain());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -59,24 +74,12 @@ public class Dns01Challenge extends TokenChallenge {
|
|||
* Domain name to be validated
|
||||
* @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note
|
||||
* the trailing full stop character).
|
||||
* @since 2.14
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public static String toRRName(String domain) {
|
||||
public String getRRName(String domain) {
|
||||
return RECORD_NAME_PREFIX + '.' + domain + '.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new generic {@link Dns01Challenge} object.
|
||||
*
|
||||
* @param login
|
||||
* {@link Login} the resource is bound with
|
||||
* @param data
|
||||
* {@link JSON} challenge data
|
||||
*/
|
||||
public Dns01Challenge(Login login, JSON data) {
|
||||
super(login, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the digest string to be set in the domain's {@code _acme-challenge} TXT
|
||||
* record.
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2025 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.challenge;
|
||||
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.*;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.shredzone.acme4j.Identifier;
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
|
||||
/**
|
||||
* Implements the {@value TYPE} challenge. It requires a specific DNS record for domain
|
||||
* validation. See the acme4j documentation for a detailed explanation.
|
||||
*
|
||||
* @draft This class is currently based on an RFC draft. It may be changed or removed
|
||||
* without notice to reflect future changes to the draft. SemVer rules do not apply here.
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public class DnsAccount01Challenge extends TokenChallenge {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -1098129409378900733L;
|
||||
|
||||
/**
|
||||
* Challenge type name: {@value}
|
||||
*/
|
||||
public static final String TYPE = "dns-account-01";
|
||||
|
||||
/**
|
||||
* Creates a new generic {@link DnsAccount01Challenge} object.
|
||||
*
|
||||
* @param login
|
||||
* {@link Login} the resource is bound with
|
||||
* @param data
|
||||
* {@link JSON} challenge data
|
||||
*/
|
||||
public DnsAccount01Challenge(Login login, JSON data) {
|
||||
super(login, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
|
||||
* record.
|
||||
*
|
||||
* @param identifier
|
||||
* {@link Identifier} to be validated
|
||||
* @return Resource Record name (e.g.
|
||||
* {@code _ujmmovf2vn55tgye._acme-challenge.example.org.}, note the trailing full stop
|
||||
* character).
|
||||
*/
|
||||
public String getRRName(Identifier identifier) {
|
||||
return getRRName(identifier.getDomain());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
|
||||
* record.
|
||||
*
|
||||
* @param domain
|
||||
* Domain name to be validated
|
||||
* @return Resource Record name (e.g.
|
||||
* {@code _ujmmovf2vn55tgye._acme-challenge.example.org.}, note the trailing full stop
|
||||
* character).
|
||||
*/
|
||||
public String getRRName(String domain) {
|
||||
return getPrefix(getLogin().getAccount().getLocation()) + '.' + domain + '.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the digest string to be set in the domain's TXT record.
|
||||
*/
|
||||
public String getDigest() {
|
||||
return base64UrlEncode(sha256hash(getAuthorization()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the prefix of an account location.
|
||||
*/
|
||||
private String getPrefix(URL accountLocation) {
|
||||
var urlHash = sha256hash(accountLocation.toExternalForm());
|
||||
var hash = base32Encode(Arrays.copyOfRange(urlHash, 0, 10));
|
||||
return "_" + hash.toLowerCase(Locale.ENGLISH)
|
||||
+ "._acme-challenge";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean acceptable(String type) {
|
||||
return TYPE.equals(type);
|
||||
}
|
||||
|
||||
}
|
|
@ -13,6 +13,8 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.challenge;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
|
||||
|
@ -22,6 +24,7 @@ import org.shredzone.acme4j.toolbox.JSON;
|
|||
* detailed explanation.
|
||||
*/
|
||||
public class Http01Challenge extends TokenChallenge {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 3322211185872544605L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -16,6 +16,7 @@ package org.shredzone.acme4j.challenge;
|
|||
import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serial;
|
||||
import java.security.KeyPair;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
|
@ -32,6 +33,7 @@ import org.shredzone.acme4j.util.CertificateUtils;
|
|||
* @since 2.1
|
||||
*/
|
||||
public class TlsAlpn01Challenge extends TokenChallenge {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -5590351078176091228L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -15,6 +15,8 @@ package org.shredzone.acme4j.challenge;
|
|||
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.toolbox.AcmeUtils;
|
||||
|
@ -26,6 +28,7 @@ import org.shredzone.acme4j.toolbox.JoseUtils;
|
|||
* and {@code keyAuthorization}.
|
||||
*/
|
||||
public class TokenChallenge extends Challenge {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1634133407432681800L;
|
||||
|
||||
protected static final String KEY_TOKEN = "token";
|
||||
|
|
|
@ -26,7 +26,6 @@ import edu.umd.cs.findbugs.annotations.Nullable;
|
|||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
||||
|
||||
|
@ -156,15 +155,6 @@ public interface Connection extends AutoCloseable {
|
|||
*/
|
||||
Optional<Instant> getRetryAfter();
|
||||
|
||||
/**
|
||||
* Throws an {@link AcmeRetryAfterException} if the last status was HTTP Accepted and
|
||||
* a Retry-After header was received.
|
||||
*
|
||||
* @param message
|
||||
* Message to be sent along with the {@link AcmeRetryAfterException}
|
||||
*/
|
||||
void handleRetryAfter(String message) throws AcmeException;
|
||||
|
||||
/**
|
||||
* Gets the nonce from the nonce header.
|
||||
*
|
||||
|
|
|
@ -15,7 +15,6 @@ package org.shredzone.acme4j.connector;
|
|||
|
||||
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
|
||||
import static java.util.function.Predicate.not;
|
||||
import static java.util.stream.Collectors.toUnmodifiableList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
@ -51,7 +50,6 @@ import org.shredzone.acme4j.exception.AcmeException;
|
|||
import org.shredzone.acme4j.exception.AcmeNetworkException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.exception.AcmeRateLimitedException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.exception.AcmeServerException;
|
||||
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
|
||||
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
|
||||
|
@ -133,7 +131,7 @@ public class DefaultConnection implements Connection {
|
|||
|
||||
var rc = getResponse().statusCode();
|
||||
if (rc != HTTP_OK && rc != HTTP_NO_CONTENT) {
|
||||
throwAcmeException();
|
||||
throw new AcmeException("Server responded with HTTP " + rc + " while trying to retrieve a nonce");
|
||||
}
|
||||
|
||||
session.setNonce(getNonce()
|
||||
|
@ -223,7 +221,7 @@ public class DefaultConnection implements Connection {
|
|||
var cf = CertificateFactory.getInstance("X.509");
|
||||
return cf.generateCertificates(in).stream()
|
||||
.map(X509Certificate.class::cast)
|
||||
.collect(toUnmodifiableList());
|
||||
.toList();
|
||||
} catch (IOException ex) {
|
||||
throw new AcmeNetworkException(ex);
|
||||
} catch (CertificateException ex) {
|
||||
|
@ -231,14 +229,6 @@ public class DefaultConnection implements Connection {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) throws AcmeException {
|
||||
var retryAfter = getRetryAfter();
|
||||
if (retryAfter.isPresent()) {
|
||||
throw new AcmeRetryAfterException(message, retryAfter.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getNonce() {
|
||||
var nonceHeaderOpt = getResponse().headers()
|
||||
|
@ -314,7 +304,7 @@ public class DefaultConnection implements Connection {
|
|||
public Collection<URL> getLinks(String relation) {
|
||||
return collectLinks(relation).stream()
|
||||
.map(this::resolveRelative)
|
||||
.collect(toUnmodifiableList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -623,7 +613,7 @@ public class DefaultConnection implements Connection {
|
|||
.filter(Matcher::matches)
|
||||
.map(m -> m.group(1))
|
||||
.peek(location -> LOG.debug("Link: {} -> {}", relation, location))
|
||||
.collect(toUnmodifiableList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -19,6 +19,7 @@ import java.net.http.HttpClient;
|
|||
import java.net.http.HttpRequest;
|
||||
import java.util.Properties;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
|
@ -62,6 +63,7 @@ public class HttpConnector {
|
|||
* Creates a new {@link HttpConnector} that is using the given
|
||||
* {@link NetworkSettings}.
|
||||
*/
|
||||
@SuppressFBWarnings("EI_EXPOSE_REP2") // behavior is intended
|
||||
public HttpConnector(NetworkSettings networkSettings) {
|
||||
this.networkSettings = networkSettings;
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ public class NetworkSettings {
|
|||
public static final String GZIP_PROPERTY_NAME = "org.shredzone.acme4j.gzip_compression";
|
||||
|
||||
private ProxySelector proxySelector = HttpClient.Builder.NO_PROXY;
|
||||
private Duration timeout = Duration.ofSeconds(10);
|
||||
private Duration timeout = Duration.ofSeconds(30);
|
||||
private @Nullable Authenticator authenticator = null;
|
||||
private boolean compression = true;
|
||||
|
||||
|
|
|
@ -13,12 +13,13 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.connector;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Iterator;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
|
@ -58,10 +59,10 @@ public class ResourceIterator<T extends AcmeResource> implements Iterator<T> {
|
|||
* {@link Login} and {@link URL}.
|
||||
*/
|
||||
public ResourceIterator(Login login, String field, @Nullable URL start, BiFunction<Login, URL, T> creator) {
|
||||
this.login = Objects.requireNonNull(login, "login");
|
||||
this.field = Objects.requireNonNull(field, "field");
|
||||
this.login = requireNonNull(login, "login");
|
||||
this.field = requireNonNull(field, "field");
|
||||
this.nextUrl = start;
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.creator = requireNonNull(creator, "creator");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -141,7 +142,7 @@ public class ResourceIterator<T extends AcmeResource> implements Iterator<T> {
|
|||
private void readAndQueue() throws AcmeException {
|
||||
var session = login.getSession();
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendSignedPostAsGetRequest(nextUrl, login);
|
||||
conn.sendSignedPostAsGetRequest(requireNonNull(nextUrl), login);
|
||||
fillUrlList(conn.readJsonResponse());
|
||||
|
||||
nextUrl = conn.getLinks("next").stream().findFirst().orElse(null);
|
||||
|
|
|
@ -13,10 +13,13 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* The root class of all checked acme4j exceptions.
|
||||
*/
|
||||
public class AcmeException extends Exception {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -2935088954705632025L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j.exception;
|
|||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
|
||||
import org.shredzone.acme4j.AcmeResource;
|
||||
|
@ -26,6 +27,7 @@ import org.shredzone.acme4j.AcmeResource;
|
|||
* thrown by getter methods, so the API is not polluted with checked exceptions.
|
||||
*/
|
||||
public class AcmeLazyLoadingException extends RuntimeException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1000353433913721901L;
|
||||
|
||||
private final Class<? extends AcmeResource> type;
|
||||
|
|
|
@ -14,12 +14,14 @@
|
|||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* A general network error has occured while communicating with the server (e.g. network
|
||||
* timeout).
|
||||
*/
|
||||
public class AcmeNetworkException extends AcmeException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 2054398693543329179L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,12 +13,15 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* A runtime exception that is thrown if the ACME server does not support a certain
|
||||
* feature. It might be either because that feature is optional, or because the server
|
||||
* is not fully RFC compliant.
|
||||
*/
|
||||
public class AcmeNotSupportedException extends AcmeProtocolException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 3434074002226584731L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,12 +13,15 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* A runtime exception that is thrown when the response of the server is violating the
|
||||
* RFC, and could not be handled or parsed for that reason. It is an indicator that the CA
|
||||
* does not fully comply with the RFC, and is usually not expected to be thrown.
|
||||
*/
|
||||
public class AcmeProtocolException extends RuntimeException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 2031203835755725193L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
|
@ -28,6 +29,7 @@ import org.shredzone.acme4j.Problem;
|
|||
* further explains the rate limit that was exceeded.
|
||||
*/
|
||||
public class AcmeRateLimitedException extends AcmeServerException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 4150484059796413069L;
|
||||
|
||||
private final @Nullable Instant retryAfter;
|
||||
|
@ -49,8 +51,7 @@ public class AcmeRateLimitedException extends AcmeServerException {
|
|||
@Nullable Collection<URL> documents) {
|
||||
super(problem);
|
||||
this.retryAfter = retryAfter;
|
||||
this.documents =
|
||||
documents != null ? Collections.unmodifiableCollection(documents) : Collections.emptyList();
|
||||
this.documents = documents != null ? documents : Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,7 +67,7 @@ public class AcmeRateLimitedException extends AcmeServerException {
|
|||
* Empty if the server did not provide such URLs.
|
||||
*/
|
||||
public Collection<URL> getDocuments() {
|
||||
return documents;
|
||||
return Collections.unmodifiableCollection(documents);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2016 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.exception;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A server side process has not been completed yet. The server also provides an estimate
|
||||
* of when the process is expected to complete.
|
||||
*/
|
||||
public class AcmeRetryAfterException extends AcmeException {
|
||||
private static final long serialVersionUID = 4461979121063649905L;
|
||||
|
||||
private final Instant retryAfter;
|
||||
|
||||
/**
|
||||
* Creates a new {@link AcmeRetryAfterException}.
|
||||
*
|
||||
* @param msg
|
||||
* Error details
|
||||
* @param retryAfter
|
||||
* retry-after date returned by the server
|
||||
*/
|
||||
public AcmeRetryAfterException(String msg, Instant retryAfter) {
|
||||
super(msg);
|
||||
this.retryAfter = Objects.requireNonNull(retryAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the retry-after instant returned by the server. This is only an estimate
|
||||
* of when a retry attempt might succeed.
|
||||
*/
|
||||
public Instant getRetryAfter() {
|
||||
return retryAfter;
|
||||
}
|
||||
|
||||
}
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
|
||||
|
@ -26,6 +27,7 @@ import org.shredzone.acme4j.Problem;
|
|||
* individually.
|
||||
*/
|
||||
public class AcmeServerException extends AcmeException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 5971622508467042792L;
|
||||
|
||||
private final Problem problem;
|
||||
|
|
|
@ -13,6 +13,8 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
import org.shredzone.acme4j.Problem;
|
||||
|
||||
/**
|
||||
|
@ -20,6 +22,7 @@ import org.shredzone.acme4j.Problem;
|
|||
* will give further details (e.g. "client IP is blocked").
|
||||
*/
|
||||
public class AcmeUnauthorizedException extends AcmeServerException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 9064697508262919366L;
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
|
@ -28,6 +29,7 @@ import org.shredzone.acme4j.Problem;
|
|||
* requires an agreement to the new terms before proceeding.
|
||||
*/
|
||||
public class AcmeUserActionRequiredException extends AcmeServerException {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 7719055447283858352L;
|
||||
|
||||
private final @Nullable URI tosUri;
|
||||
|
@ -69,4 +71,11 @@ public class AcmeUserActionRequiredException extends AcmeServerException {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getProblem().getInstance()
|
||||
.map(uri -> "Please visit " + uri + " - details: " + getProblem())
|
||||
.orElseGet(super::toString);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ import org.shredzone.acme4j.Login;
|
|||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.challenge.Challenge;
|
||||
import org.shredzone.acme4j.challenge.Dns01Challenge;
|
||||
import org.shredzone.acme4j.challenge.DnsAccount01Challenge;
|
||||
import org.shredzone.acme4j.challenge.Http01Challenge;
|
||||
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
|
||||
import org.shredzone.acme4j.challenge.TokenChallenge;
|
||||
|
@ -83,6 +84,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider {
|
|||
var map = new HashMap<String, ChallengeProvider>();
|
||||
|
||||
map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
|
||||
map.put(DnsAccount01Challenge.TYPE, DnsAccount01Challenge::new);
|
||||
map.put(Http01Challenge.TYPE, Http01Challenge::new);
|
||||
map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j.provider;
|
|||
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Optional;
|
||||
import java.util.ServiceLoader;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
|
@ -96,4 +97,17 @@ public interface AcmeProvider {
|
|||
@Nullable
|
||||
Challenge createChallenge(Login login, JSON data);
|
||||
|
||||
/**
|
||||
* Returns a proposal for the EAB MAC algorithm to be used. Only set if the CA
|
||||
* requires External Account Binding and the MAC algorithm cannot be correctly derived
|
||||
* from the MAC key. Empty otherwise.
|
||||
*
|
||||
* @return Proposed MAC algorithm to be used for EAB, or empty for the default
|
||||
* behavior.
|
||||
* @since 3.5.0
|
||||
*/
|
||||
default Optional<String> getProposedEabMacAlgorithm() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ public class GenericAcmeProvider extends AbstractAcmeProvider {
|
|||
@Override
|
||||
public URL resolve(URI serverUri) {
|
||||
try {
|
||||
return new URL(serverUri.getScheme(), serverUri.getHost(), serverUri.getPort(), serverUri.getPath());
|
||||
return serverUri.toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new IllegalArgumentException("Bad generic server URI", ex);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2025 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.actalis;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Map;
|
||||
|
||||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
|
||||
import org.shredzone.acme4j.provider.AcmeProvider;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
|
||||
/**
|
||||
* An {@link AcmeProvider} for <em>Actalis</em>.
|
||||
* <p>
|
||||
* The {@code serverUri} is {@code "acme://actalis.com"} for the production server.
|
||||
* <p>
|
||||
* If you want to use <em>Actalis</em>, always prefer to use this provider.
|
||||
*
|
||||
* @see <a href="https://www.actalis.com/">Actalis S.p.A.</a>
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public class ActalisAcmeProvider extends AbstractAcmeProvider {
|
||||
|
||||
private static final String PRODUCTION_DIRECTORY_URL = "https://acme-api.actalis.com/acme/directory";
|
||||
|
||||
@Override
|
||||
public boolean accepts(URI serverUri) {
|
||||
return "acme".equals(serverUri.getScheme())
|
||||
&& "actalis.com".equals(serverUri.getHost());
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL resolve(URI serverUri) {
|
||||
var path = serverUri.getPath();
|
||||
String directoryUrl;
|
||||
if (path == null || path.isEmpty() || "/".equals(path)) {
|
||||
directoryUrl = PRODUCTION_DIRECTORY_URL;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown URI " + serverUri);
|
||||
}
|
||||
|
||||
try {
|
||||
return URI.create(directoryUrl).toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException(directoryUrl, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public JSON directory(Session session, URI serverUri) throws AcmeException {
|
||||
// This is a workaround as actalis.com uses "home" instead of "website" to
|
||||
// refer to its homepage in the metadata.
|
||||
var superdirectory = super.directory(session, serverUri);
|
||||
if (superdirectory == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var directory = superdirectory.toMap();
|
||||
var meta = directory.get("meta");
|
||||
if (meta instanceof Map) {
|
||||
var metaMap = ((Map<String, Object>) meta);
|
||||
if (metaMap.containsKey("home") && !metaMap.containsKey("website")) {
|
||||
metaMap.put("website", metaMap.remove("home"));
|
||||
}
|
||||
}
|
||||
return JSON.fromMap(directory);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2025 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 an {@link org.shredzone.acme4j.provider.AcmeProvider} for the
|
||||
* Actalis server.
|
||||
*
|
||||
* @see <a href="https://www.actalis.com/">Actalis S.p.A.</a>
|
||||
*/
|
||||
@ReturnValuesAreNonnullByDefault
|
||||
@DefaultAnnotationForParameters(NonNull.class)
|
||||
@DefaultAnnotationForFields(NonNull.class)
|
||||
package org.shredzone.acme4j.provider.actalis;
|
||||
|
||||
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;
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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.buypass;
|
||||
|
||||
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 the <em>Buypass</em>.
|
||||
* <p>
|
||||
* The {@code serverUri} is {@code "acme://buypass.com"} for the production server,
|
||||
* and {@code "acme://buypass.com/staging"} for the staging server.
|
||||
*
|
||||
* @see <a href="https://www.buypass.com/products/tls-ssl-certificates/go-ssl">https://www.buypass.com/products/tls-ssl-certificates/go-ssl</a>
|
||||
* @since 3.5.0
|
||||
*/
|
||||
public class BuypassAcmeProvider extends AbstractAcmeProvider {
|
||||
|
||||
private static final String PRODUCTION_DIRECTORY_URL = "https://api.buypass.com/acme/directory";
|
||||
private static final String STAGING_DIRECTORY_URL = "https://api.test4.buypass.no/acme/directory";
|
||||
|
||||
@Override
|
||||
public boolean accepts(URI serverUri) {
|
||||
return "acme".equals(serverUri.getScheme())
|
||||
&& "buypass.com".equals(serverUri.getHost());
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL resolve(URI serverUri) {
|
||||
var path = serverUri.getPath();
|
||||
String directoryUrl;
|
||||
if (path == null || path.isEmpty() || "/".equals(path)) {
|
||||
directoryUrl = PRODUCTION_DIRECTORY_URL;
|
||||
} else if ("/staging".equals(path)) {
|
||||
directoryUrl = STAGING_DIRECTORY_URL;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown URI " + serverUri);
|
||||
}
|
||||
|
||||
try {
|
||||
return URI.create(directoryUrl).toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException(directoryUrl, ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 {@link org.shredzone.acme4j.provider.AcmeProvider} for+
|
||||
* Buypass.
|
||||
*
|
||||
* @see <a href="https://www.buypass.com/products/tls-ssl-certificates/go-ssl">https://www.buypass.com/products/tls-ssl-certificates/go-ssl</a>
|
||||
*/
|
||||
@ReturnValuesAreNonnullByDefault
|
||||
@DefaultAnnotationForParameters(NonNull.class)
|
||||
@DefaultAnnotationForFields(NonNull.class)
|
||||
package org.shredzone.acme4j.provider.buypass;
|
||||
|
||||
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;
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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.google;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.jose4j.jws.AlgorithmIdentifiers;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
|
||||
import org.shredzone.acme4j.provider.AcmeProvider;
|
||||
|
||||
/**
|
||||
* An {@link AcmeProvider} for the <em>Google Trust Services</em>.
|
||||
* <p>
|
||||
* The {@code serverUri} is {@code "acme://pki.goog"} for the production server,
|
||||
* and {@code "acme://pki.goog/staging"} for the staging server.
|
||||
*
|
||||
* @see <a href="https://pki.goog/">https://pki.goog/</a>
|
||||
* @since 3.5.0
|
||||
*/
|
||||
public class GoogleAcmeProvider extends AbstractAcmeProvider {
|
||||
|
||||
private static final String PRODUCTION_DIRECTORY_URL = "https://dv.acme-v02.api.pki.goog/directory";
|
||||
private static final String STAGING_DIRECTORY_URL = "https://dv.acme-v02.test-api.pki.goog/directory";
|
||||
|
||||
@Override
|
||||
public boolean accepts(URI serverUri) {
|
||||
return "acme".equals(serverUri.getScheme())
|
||||
&& "pki.goog".equals(serverUri.getHost());
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL resolve(URI serverUri) {
|
||||
var path = serverUri.getPath();
|
||||
String directoryUrl;
|
||||
if (path == null || path.isEmpty() || "/".equals(path)) {
|
||||
directoryUrl = PRODUCTION_DIRECTORY_URL;
|
||||
} else if ("/staging".equals(path)) {
|
||||
directoryUrl = STAGING_DIRECTORY_URL;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown URI " + serverUri);
|
||||
}
|
||||
|
||||
try {
|
||||
return URI.create(directoryUrl).toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException(directoryUrl, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getProposedEabMacAlgorithm() {
|
||||
return Optional.of(AlgorithmIdentifiers.HMAC_SHA256);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 {@link org.shredzone.acme4j.provider.AcmeProvider} for the
|
||||
* Google Trust Services.
|
||||
*
|
||||
* @see <a href="https://pki.goog/">https://pki.goog/</a>
|
||||
*/
|
||||
@ReturnValuesAreNonnullByDefault
|
||||
@DefaultAnnotationForParameters(NonNull.class)
|
||||
@DefaultAnnotationForFields(NonNull.class)
|
||||
package org.shredzone.acme4j.provider.google;
|
||||
|
||||
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;
|
|
@ -46,7 +46,7 @@ public class LetsEncryptAcmeProvider extends AbstractAcmeProvider {
|
|||
public URL resolve(URI serverUri) {
|
||||
var path = serverUri.getPath();
|
||||
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;
|
||||
} else if ("/staging".equals(path)) {
|
||||
directoryUrl = STAGING_DIRECTORY_URL;
|
||||
|
@ -55,7 +55,7 @@ public class LetsEncryptAcmeProvider extends AbstractAcmeProvider {
|
|||
}
|
||||
|
||||
try {
|
||||
return new URL(directoryUrl);
|
||||
return URI.create(directoryUrl).toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException(directoryUrl, ex);
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j.provider.pebble;
|
|||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
@ -50,7 +51,7 @@ public class PebbleAcmeProvider extends AbstractAcmeProvider {
|
|||
var path = serverUri.getPath();
|
||||
int port = serverUri.getPort() != -1 ? serverUri.getPort() : PEBBLE_DEFAULT_PORT;
|
||||
|
||||
var baseUrl = new URL("https://localhost:" + port + "/dir");
|
||||
var baseUrl = URI.create("https://localhost:" + port + "/dir").toURL();
|
||||
|
||||
if (path != null && !path.isEmpty() && !"/".equals(path)) {
|
||||
baseUrl = parsePath(path);
|
||||
|
@ -77,7 +78,11 @@ public class PebbleAcmeProvider extends AbstractAcmeProvider {
|
|||
if (m.group(2) != null) {
|
||||
port = Integer.parseInt(m.group(2));
|
||||
}
|
||||
return new URL("https", host, port, "/dir");
|
||||
try {
|
||||
return new URI("https", null, host, port, "/dir", null, null).toURL();
|
||||
} catch (URISyntaxException ex) {
|
||||
throw new IllegalArgumentException("Malformed Pebble host/port: " + path);
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid Pebble host/port: " + path);
|
||||
}
|
||||
|
|
|
@ -20,21 +20,26 @@ import java.security.KeyStore;
|
|||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import org.shredzone.acme4j.connector.HttpConnector;
|
||||
import org.shredzone.acme4j.connector.NetworkSettings;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* {@link HttpConnector} to be used for Pebble. Pebble uses a static, self-signed SSL
|
||||
* certificate.
|
||||
*/
|
||||
public class PebbleHttpConnector extends HttpConnector {
|
||||
private static @Nullable SSLContext sslContext = null;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PebbleHttpConnector.class);
|
||||
private static final AtomicReference<SSLContext> SSL_CONTEXT_REF = new AtomicReference<>();
|
||||
|
||||
public PebbleHttpConnector(NetworkSettings settings) {
|
||||
super(settings);
|
||||
|
@ -51,23 +56,52 @@ public class PebbleHttpConnector extends HttpConnector {
|
|||
* Lazily creates an {@link SSLContext} that exclusively accepts the Pebble
|
||||
* certificate.
|
||||
*/
|
||||
protected synchronized SSLContext createSSLContext() {
|
||||
if (sslContext == null) {
|
||||
try (var in = getClass().getResourceAsStream("/org/shredzone/acme4j/provider/pebble/pebble.truststore")) {
|
||||
var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
keystore.load(in, "acme4j".toCharArray());
|
||||
protected SSLContext createSSLContext() {
|
||||
if (SSL_CONTEXT_REF.get() == null) {
|
||||
try {
|
||||
var keystore = readPemFile("/pebble.minica.pem")
|
||||
.or(() -> readPemFile("/META-INF/pebble.minica.pem"))
|
||||
.or(() -> readPemFile("/org/shredzone/acme4j/provider/pebble/pebble.minica.pem"))
|
||||
.orElseThrow(() -> new RuntimeException("Could not find a Pebble root certificate"));
|
||||
|
||||
var tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init(keystore);
|
||||
|
||||
sslContext = SSLContext.getInstance("TLS");
|
||||
var sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, tmf.getTrustManagers(), null);
|
||||
} catch (IOException | KeyStoreException | CertificateException
|
||||
| NoSuchAlgorithmException | KeyManagementException ex) {
|
||||
SSL_CONTEXT_REF.set(sslContext);
|
||||
} catch (KeyStoreException | NoSuchAlgorithmException | KeyManagementException ex) {
|
||||
throw new RuntimeException("Could not create truststore", ex);
|
||||
}
|
||||
}
|
||||
return Objects.requireNonNull(sslContext);
|
||||
return Objects.requireNonNull(SSL_CONTEXT_REF.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a PEM file from a resource, and returns a {@link KeyStore} that uses this
|
||||
* certificate as root CA.
|
||||
*
|
||||
* @param resource
|
||||
* Resource name
|
||||
* @return A {@link KeyStore} if the resource could be read successfully, otherwise
|
||||
* empty.
|
||||
*/
|
||||
private Optional<KeyStore> readPemFile(String resource) {
|
||||
try (var in = getClass().getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var cf = CertificateFactory.getInstance("X.509");
|
||||
var cert = cf.generateCertificate(in);
|
||||
var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
keystore.load(null, "acme4j".toCharArray());
|
||||
keystore.setCertificateEntry("pebble", cert);
|
||||
return Optional.of(keystore);
|
||||
} catch (IOException | KeyStoreException | CertificateException
|
||||
| NoSuchAlgorithmException ex) {
|
||||
LOG.error("Failed to read PEM from resource '{}'", resource, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* 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.sslcom;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.Map;
|
||||
|
||||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
|
||||
import org.shredzone.acme4j.provider.AcmeProvider;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
|
||||
/**
|
||||
* An {@link AcmeProvider} for <em>SSL.com</em>.
|
||||
* <p>
|
||||
* The {@code serverUri} is {@code "acme://ssl.com"} for the production server,
|
||||
* and {@code "acme://acme-try.ssl.com"} for a testing server.
|
||||
* <p>
|
||||
* If you want to use <em>SSL.com</em>, always prefer to use this provider.
|
||||
*
|
||||
* @see <a href="https://ssl.com/">SSL.com</a>
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class SslComAcmeProvider extends AbstractAcmeProvider {
|
||||
|
||||
private static final String PRODUCTION_ECC_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-ecc";
|
||||
private static final String PRODUCTION_RSA_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-rsa";
|
||||
private static final String STAGING_ECC_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-ecc";
|
||||
private static final String STAGING_RSA_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-rsa";
|
||||
|
||||
@Override
|
||||
public boolean accepts(URI serverUri) {
|
||||
return "acme".equals(serverUri.getScheme())
|
||||
&& "ssl.com".equals(serverUri.getHost());
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL resolve(URI serverUri) {
|
||||
var path = serverUri.getPath();
|
||||
String directoryUrl;
|
||||
if (path == null || path.isEmpty() || "/".equals(path) || "/ecc".equals(path)) {
|
||||
directoryUrl = PRODUCTION_ECC_DIRECTORY_URL;
|
||||
} else if ("/rsa".equals(path)) {
|
||||
directoryUrl = PRODUCTION_RSA_DIRECTORY_URL;
|
||||
} else if ("/staging".equals(path) || "/staging/ecc".equals(path)) {
|
||||
directoryUrl = STAGING_ECC_DIRECTORY_URL;
|
||||
} else if ("/staging/rsa".equals(path)) {
|
||||
directoryUrl = STAGING_RSA_DIRECTORY_URL;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown URI " + serverUri);
|
||||
}
|
||||
|
||||
try {
|
||||
return URI.create(directoryUrl).toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException(directoryUrl, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public JSON directory(Session session, URI serverUri) throws AcmeException {
|
||||
// This is a workaround for a bug at SSL.com. It requires account registration
|
||||
// by EAB, but the "externalAccountRequired" flag in the directory is set to
|
||||
// false. This patch reads the directory and forcefully sets the flag to true.
|
||||
// The entire method can be removed once it is fixed on SSL.com side.
|
||||
var superdirectory = super.directory(session, serverUri);
|
||||
if (superdirectory == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var directory = superdirectory.toMap();
|
||||
var meta = directory.get("meta");
|
||||
if (meta instanceof Map) {
|
||||
var metaMap = ((Map<String, Object>) meta);
|
||||
metaMap.remove("externalAccountRequired");
|
||||
metaMap.put("externalAccountRequired", true);
|
||||
}
|
||||
return JSON.fromMap(directory);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2020 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 SSL.com
|
||||
* {@link org.shredzone.acme4j.provider.AcmeProvider}.
|
||||
*
|
||||
* @see <a href="https://ssl.com/">SSL.com</a>
|
||||
*/
|
||||
@ReturnValuesAreNonnullByDefault
|
||||
@DefaultAnnotationForParameters(NonNull.class)
|
||||
@DefaultAnnotationForFields(NonNull.class)
|
||||
package org.shredzone.acme4j.provider.sslcom;
|
||||
|
||||
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;
|
|
@ -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 URI.create(directoryUrl).toURL();
|
||||
} 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;
|
|
@ -16,21 +16,29 @@ package org.shredzone.acme4j.toolbox;
|
|||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.Writer;
|
||||
import java.net.IDN;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import org.bouncycastle.asn1.ASN1Integer;
|
||||
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
|
||||
import org.bouncycastle.asn1.x509.Certificate;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
|
||||
/**
|
||||
|
@ -64,6 +72,8 @@ public final class AcmeUtils {
|
|||
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
|
||||
|
||||
private static final char[] BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
|
||||
|
||||
/**
|
||||
* Enumeration of PEM labels.
|
||||
*/
|
||||
|
@ -146,6 +156,59 @@ public final class AcmeUtils {
|
|||
return URL_DECODER.decode(base64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encodes a byte array.
|
||||
*
|
||||
* @param data Byte array to encode
|
||||
* @return Base32 encoded data (includes padding)
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public static String base32Encode(byte[] data) {
|
||||
var result = new StringBuilder();
|
||||
var unconverted = new int[5];
|
||||
var converted = new int[8];
|
||||
|
||||
for (var ix = 0; ix < (data.length + 4) / 5; ix++) {
|
||||
var blocklen = unconverted.length;
|
||||
for (var pos = 0; pos < unconverted.length; pos++) {
|
||||
if ((ix * 5 + pos) < data.length) {
|
||||
unconverted[pos] = data[ix * 5 + pos] & 0xFF;
|
||||
} else {
|
||||
unconverted[pos] = 0;
|
||||
blocklen--;
|
||||
}
|
||||
}
|
||||
|
||||
converted[0] = (unconverted[0] >> 3) & 0x1F;
|
||||
converted[1] = ((unconverted[0] & 0x07) << 2) | ((unconverted[1] >> 6) & 0x03);
|
||||
converted[2] = (unconverted[1] >> 1) & 0x1F;
|
||||
converted[3] = ((unconverted[1] & 0x01) << 4) | ((unconverted[2] >> 4) & 0x0F);
|
||||
converted[4] = ((unconverted[2] & 0x0F) << 1) | ((unconverted[3] >> 7) & 0x01);
|
||||
converted[5] = (unconverted[3] >> 2) & 0x1F;
|
||||
converted[6] = ((unconverted[3] & 0x03) << 3) | ((unconverted[4] >> 5) & 0x07);
|
||||
converted[7] = unconverted[4] & 0x1F;
|
||||
|
||||
var padding = switch (blocklen) {
|
||||
case 1 -> 6;
|
||||
case 2 -> 4;
|
||||
case 3 -> 3;
|
||||
case 4 -> 1;
|
||||
case 5 -> 0;
|
||||
default -> throw new IllegalArgumentException("blocklen " + blocklen + " out of range");
|
||||
};
|
||||
|
||||
Arrays.stream(converted)
|
||||
.limit(converted.length - padding)
|
||||
.map(v -> BASE32_ALPHABET[v])
|
||||
.forEach(v -> result.append((char) v));
|
||||
|
||||
if (padding > 0) {
|
||||
result.append("=".repeat(padding));
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given {@link String} is a valid base64url encoded value.
|
||||
*
|
||||
|
@ -175,7 +238,7 @@ public final class AcmeUtils {
|
|||
*/
|
||||
public static String toAce(String domain) {
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
return IDN.toASCII(domain.trim()).toLowerCase();
|
||||
return IDN.toASCII(domain.trim()).toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -299,7 +362,7 @@ public final class AcmeUtils {
|
|||
if (charset != null && !"utf-8".equalsIgnoreCase(charset)) {
|
||||
throw new AcmeProtocolException("Unsupported charset " + charset);
|
||||
}
|
||||
return m.group(1).trim().toLowerCase();
|
||||
return m.group(1).trim().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
@ -323,4 +386,56 @@ public final class AcmeUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the certificate's unique identifier for renewal.
|
||||
*
|
||||
* @param certificate
|
||||
* Certificate to get the unique identifier for.
|
||||
* @return Unique identifier
|
||||
* @throws AcmeProtocolException
|
||||
* if the certificate is invalid or does not provide the necessary
|
||||
* information.
|
||||
*/
|
||||
public static String getRenewalUniqueIdentifier(X509Certificate certificate) {
|
||||
try {
|
||||
var cert = new X509CertificateHolder(certificate.getEncoded());
|
||||
|
||||
var aki = Optional.of(cert)
|
||||
.map(X509CertificateHolder::getExtensions)
|
||||
.map(AuthorityKeyIdentifier::fromExtensions)
|
||||
.map(AuthorityKeyIdentifier::getKeyIdentifier)
|
||||
.map(AcmeUtils::base64UrlEncode)
|
||||
.orElseThrow(() -> new AcmeProtocolException("Missing or invalid Authority Key Identifier"));
|
||||
|
||||
var sn = Optional.of(cert)
|
||||
.map(X509CertificateHolder::toASN1Structure)
|
||||
.map(Certificate::getSerialNumber)
|
||||
.map(AcmeUtils::getRawInteger)
|
||||
.map(AcmeUtils::base64UrlEncode)
|
||||
.orElseThrow(() -> new AcmeProtocolException("Missing or invalid serial number"));
|
||||
|
||||
return aki + '.' + sn;
|
||||
} catch (Exception ex) {
|
||||
throw new AcmeProtocolException("Invalid certificate", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw integer array from ASN1Integer. This is done by encoding it to a byte
|
||||
* array, and then skipping the INTEGER identifier. Other methods of ASN1Integer only
|
||||
* deliver a parsed integer value that might have been mangled.
|
||||
*
|
||||
* @param integer
|
||||
* ASN1Integer to convert to raw
|
||||
* @return Byte array of the raw integer
|
||||
*/
|
||||
private static byte[] getRawInteger(ASN1Integer integer) {
|
||||
try {
|
||||
var encoded = integer.getEncoded();
|
||||
return Arrays.copyOfRange(encoded, 2, encoded.length);
|
||||
} catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -21,8 +21,7 @@ import java.io.BufferedReader;
|
|||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
|
@ -44,7 +43,6 @@ import java.util.stream.Stream;
|
|||
import java.util.stream.StreamSupport;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
|
||||
import org.jose4j.json.JsonUtil;
|
||||
import org.jose4j.lang.JoseException;
|
||||
import org.shredzone.acme4j.Identifier;
|
||||
|
@ -57,14 +55,13 @@ import org.shredzone.acme4j.exception.AcmeProtocolException;
|
|||
* A model containing a JSON result. The content is immutable.
|
||||
*/
|
||||
public final class JSON implements Serializable {
|
||||
private static final long serialVersionUID = 3091273044605709204L;
|
||||
@Serial
|
||||
private static final long serialVersionUID = 418332625174149030L;
|
||||
|
||||
private static final JSON EMPTY_JSON = new JSON(new HashMap<>());
|
||||
|
||||
private final String path;
|
||||
|
||||
@SuppressFBWarnings("JCIP_FIELD_ISNT_FINAL_IN_IMMUTABLE_CLASS")
|
||||
private transient Map<String, Object> data; // Must not be final for deserialization
|
||||
private final Map<String, Object> data;
|
||||
|
||||
/**
|
||||
* Creates a new {@link JSON} root object.
|
||||
|
@ -118,6 +115,21 @@ public final class JSON implements Serializable {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON object from a map.
|
||||
* <p>
|
||||
* The map's content is deeply copied. Changes to the map won't reflect in the created
|
||||
* JSON structure.
|
||||
*
|
||||
* @param data
|
||||
* Map structure
|
||||
* @return {@link JSON} of the map's content.
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public static JSON fromMap(Map<String, Object> data) {
|
||||
return JSON.parse(JsonUtil.toJson(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link JSON} of an empty document.
|
||||
*
|
||||
|
@ -192,26 +204,6 @@ public final class JSON implements Serializable {
|
|||
return Collections.unmodifiableMap(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the data map in JSON.
|
||||
*/
|
||||
private void writeObject(ObjectOutputStream out) throws IOException {
|
||||
out.writeUTF(JsonUtil.toJson(data));
|
||||
out.defaultWriteObject();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize the JSON representation of the data map.
|
||||
*/
|
||||
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
|
||||
try {
|
||||
data = new HashMap<>(JsonUtil.parseJson(in.readUTF()));
|
||||
in.defaultReadObject();
|
||||
} catch (JoseException ex) {
|
||||
throw new AcmeProtocolException("Cannot deserialize", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON array.
|
||||
*/
|
||||
|
@ -354,20 +346,15 @@ public final class JSON implements Serializable {
|
|||
* Returns the value as {@link String}.
|
||||
*/
|
||||
public String asString() {
|
||||
required();
|
||||
return val.toString();
|
||||
return required().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as JSON object.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public JSON asObject() {
|
||||
required();
|
||||
try {
|
||||
return new JSON(path, (Map<String, Object>) val);
|
||||
} catch (ClassCastException ex) {
|
||||
throw new AcmeProtocolException(path + ": expected an object", ex);
|
||||
}
|
||||
return new JSON(path, (Map<String, Object>) required(Map.class));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -376,9 +363,8 @@ public final class JSON implements Serializable {
|
|||
* @since 2.8
|
||||
*/
|
||||
public JSON asEncodedObject() {
|
||||
required();
|
||||
try {
|
||||
var raw = AcmeUtils.base64UrlDecode(val.toString());
|
||||
var raw = AcmeUtils.base64UrlDecode(asString());
|
||||
return new JSON(path, JsonUtil.parseJson(new String(raw, UTF_8)));
|
||||
} catch (IllegalArgumentException | JoseException ex) {
|
||||
throw new AcmeProtocolException(path + ": expected an encoded object", ex);
|
||||
|
@ -392,7 +378,6 @@ public final class JSON implements Serializable {
|
|||
* Base {@link URL} to resolve relative links against
|
||||
*/
|
||||
public Problem asProblem(URL baseUrl) {
|
||||
required();
|
||||
return new Problem(asObject(), baseUrl);
|
||||
}
|
||||
|
||||
|
@ -402,7 +387,6 @@ public final class JSON implements Serializable {
|
|||
* @since 2.3
|
||||
*/
|
||||
public Identifier asIdentifier() {
|
||||
required();
|
||||
return new Identifier(asObject());
|
||||
}
|
||||
|
||||
|
@ -412,6 +396,7 @@ public final class JSON implements Serializable {
|
|||
* Unlike the other getters, this method returns an empty array if the value is
|
||||
* not set. Use {@link #isPresent()} to find out if the value was actually set.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Array asArray() {
|
||||
if (val == null) {
|
||||
return new Array(path, Collections.emptyList());
|
||||
|
@ -428,33 +413,22 @@ public final class JSON implements Serializable {
|
|||
* Returns the value as int.
|
||||
*/
|
||||
public int asInt() {
|
||||
required();
|
||||
try {
|
||||
return ((Number) val).intValue();
|
||||
} catch (ClassCastException ex) {
|
||||
throw new AcmeProtocolException(path + ": bad number " + val, ex);
|
||||
}
|
||||
return (required(Number.class)).intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as boolean.
|
||||
*/
|
||||
public boolean asBoolean() {
|
||||
required();
|
||||
try {
|
||||
return (Boolean) val;
|
||||
} catch (ClassCastException ex) {
|
||||
throw new AcmeProtocolException(path + ": bad boolean " + val, ex);
|
||||
}
|
||||
return required(Boolean.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as {@link URI}.
|
||||
*/
|
||||
public URI asURI() {
|
||||
required();
|
||||
try {
|
||||
return new URI(val.toString());
|
||||
return new URI(asString());
|
||||
} catch (URISyntaxException ex) {
|
||||
throw new AcmeProtocolException(path + ": bad URI " + val, ex);
|
||||
}
|
||||
|
@ -464,9 +438,8 @@ public final class JSON implements Serializable {
|
|||
* Returns the value as {@link URL}.
|
||||
*/
|
||||
public URL asURL() {
|
||||
required();
|
||||
try {
|
||||
return new URL(val.toString());
|
||||
return asURI().toURL();
|
||||
} catch (MalformedURLException ex) {
|
||||
throw new AcmeProtocolException(path + ": bad URL " + val, ex);
|
||||
}
|
||||
|
@ -476,9 +449,8 @@ public final class JSON implements Serializable {
|
|||
* Returns the value as {@link Instant}.
|
||||
*/
|
||||
public Instant asInstant() {
|
||||
required();
|
||||
try {
|
||||
return parseTimestamp(val.toString());
|
||||
return parseTimestamp(asString());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new AcmeProtocolException(path + ": bad date " + val, ex);
|
||||
}
|
||||
|
@ -490,38 +462,52 @@ public final class JSON implements Serializable {
|
|||
* @since 2.3
|
||||
*/
|
||||
public Duration asDuration() {
|
||||
required();
|
||||
try {
|
||||
return Duration.ofSeconds(((Number) val).longValue());
|
||||
} catch (ClassCastException ex) {
|
||||
throw new AcmeProtocolException(path + ": bad duration " + val, ex);
|
||||
}
|
||||
return Duration.ofSeconds(required(Number.class).longValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value as base64 decoded byte array.
|
||||
*/
|
||||
public byte[] asBinary() {
|
||||
required();
|
||||
return AcmeUtils.base64UrlDecode(val.toString());
|
||||
return AcmeUtils.base64UrlDecode(asString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parsed {@link Status}.
|
||||
*/
|
||||
public Status asStatus() {
|
||||
required();
|
||||
return Status.parse(val.toString());
|
||||
return Status.parse(asString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is present. An {@link AcmeProtocolException} is thrown if
|
||||
* the value is {@code null}.
|
||||
*
|
||||
* @return val that is guaranteed to be non-{@code null}
|
||||
*/
|
||||
private void required() {
|
||||
if (!isPresent()) {
|
||||
private Object required() {
|
||||
if (val == null) {
|
||||
throw new AcmeProtocolException(path + ": required, but not set");
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the value is present. An {@link AcmeProtocolException} is thrown if
|
||||
* the value is {@code null} or is not of the expected type.
|
||||
*
|
||||
* @param type
|
||||
* expected type
|
||||
* @return val that is guaranteed to be non-{@code null}
|
||||
*/
|
||||
private <T> T required(Class<T> type) {
|
||||
if (val == null) {
|
||||
throw new AcmeProtocolException(path + ": required, but not set");
|
||||
}
|
||||
if (!type.isInstance(val)) {
|
||||
throw new AcmeProtocolException(path + ": cannot convert to " + type.getSimpleName());
|
||||
}
|
||||
return type.cast(val);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -115,12 +115,14 @@ public final class JoseUtils {
|
|||
* {@link PublicKey} of the account to register
|
||||
* @param macKey
|
||||
* {@link SecretKey} to sign the key identifier with
|
||||
* @param macAlgorithm
|
||||
* Algorithm of the MAC key
|
||||
* @param resource
|
||||
* "newAccount" resource URL
|
||||
* @return Created JSON structure
|
||||
*/
|
||||
public static Map<String, Object> createExternalAccountBinding(String kid,
|
||||
PublicKey accountKey, SecretKey macKey, URL resource) {
|
||||
PublicKey accountKey, SecretKey macKey, String macAlgorithm, URL resource) {
|
||||
try {
|
||||
var keyJwk = PublicJsonWebKey.Factory.newPublicJwk(accountKey);
|
||||
|
||||
|
@ -128,7 +130,7 @@ public final class JoseUtils {
|
|||
innerJws.setPayload(keyJwk.toJson());
|
||||
innerJws.getHeaders().setObjectHeaderValue("url", resource);
|
||||
innerJws.getHeaders().setObjectHeaderValue("kid", kid);
|
||||
innerJws.setAlgorithmHeaderValue(macKeyAlgorithm(macKey));
|
||||
innerJws.setAlgorithmHeaderValue(macAlgorithm);
|
||||
innerJws.setKey(macKey);
|
||||
innerJws.setDoKeyValidation(false);
|
||||
innerJws.sign();
|
||||
|
@ -201,23 +203,13 @@ public final class JoseUtils {
|
|||
* there is no corresponding algorithm identifier for the key
|
||||
*/
|
||||
public static String keyAlgorithm(JsonWebKey jwk) {
|
||||
if (jwk instanceof EllipticCurveJsonWebKey) {
|
||||
var ecjwk = (EllipticCurveJsonWebKey) jwk;
|
||||
|
||||
switch (ecjwk.getCurveName()) {
|
||||
case "P-256":
|
||||
return AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256;
|
||||
|
||||
case "P-384":
|
||||
return AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384;
|
||||
|
||||
case "P-521":
|
||||
return AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512;
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown EC name "
|
||||
+ ecjwk.getCurveName());
|
||||
}
|
||||
if (jwk instanceof EllipticCurveJsonWebKey ecjwk) {
|
||||
return switch (ecjwk.getCurveName()) {
|
||||
case "P-256" -> AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256;
|
||||
case "P-384" -> AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384;
|
||||
case "P-521" -> AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512;
|
||||
default -> throw new IllegalArgumentException("Unknown EC name " + ecjwk.getCurveName());
|
||||
};
|
||||
|
||||
} else if (jwk instanceof RsaJsonWebKey) {
|
||||
return AlgorithmIdentifiers.RSA_USING_SHA256;
|
||||
|
@ -243,19 +235,15 @@ public final class JoseUtils {
|
|||
}
|
||||
|
||||
var size = macKey.getEncoded().length * 8;
|
||||
switch (size) {
|
||||
case 256:
|
||||
return AlgorithmIdentifiers.HMAC_SHA256;
|
||||
|
||||
case 384:
|
||||
return AlgorithmIdentifiers.HMAC_SHA384;
|
||||
|
||||
case 512:
|
||||
return AlgorithmIdentifiers.HMAC_SHA512;
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException("Bad key size: " + size);
|
||||
if(size < 256) {
|
||||
throw new IllegalArgumentException("Bad key size: " + size);
|
||||
}
|
||||
if (size >= 512) {
|
||||
return AlgorithmIdentifiers.HMAC_SHA512;
|
||||
} else if (size >= 384) {
|
||||
return AlgorithmIdentifiers.HMAC_SHA384;
|
||||
} else {
|
||||
return AlgorithmIdentifiers.HMAC_SHA256;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -64,25 +64,21 @@ public class CSRBuilder {
|
|||
private final List<String> namelist = new ArrayList<>();
|
||||
private final List<InetAddress> iplist = new ArrayList<>();
|
||||
private @Nullable PKCS10CertificationRequest csr = null;
|
||||
|
||||
private boolean hasCnSet = false;
|
||||
|
||||
/**
|
||||
* Adds a domain name to the CSR. The first domain name added will also be the
|
||||
* <em>Common Name</em>. All domain names will be added as <em>Subject Alternative
|
||||
* Name</em>.
|
||||
* Adds a domain name to the CSR. All domain names will be added as <em>Subject
|
||||
* Alternative Name</em>.
|
||||
* <p>
|
||||
* IDN domain names are ACE encoded automatically.
|
||||
* <p>
|
||||
* For wildcard certificates, the domain name must be prefixed with {@code "*."}.
|
||||
*
|
||||
* @param domain
|
||||
* Domain name to add
|
||||
* Domain name to add
|
||||
*/
|
||||
public void addDomain(String domain) {
|
||||
var ace = toAce(requireNonNull(domain));
|
||||
if (namelist.isEmpty()) {
|
||||
namebuilder.addRDN(BCStyle.CN, ace);
|
||||
}
|
||||
namelist.add(ace);
|
||||
namelist.add(toAce(requireNonNull(domain)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -217,11 +213,25 @@ public class CSRBuilder {
|
|||
public void addValue(ASN1ObjectIdentifier oid, String value) {
|
||||
if (requireNonNull(oid, "OID must not be null").equals(BCStyle.CN)) {
|
||||
addDomain(value);
|
||||
return;
|
||||
if (hasCnSet) {
|
||||
return;
|
||||
}
|
||||
hasCnSet = true;
|
||||
}
|
||||
namebuilder.addRDN(oid, requireNonNull(value, "attribute value must not be null"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the common name.
|
||||
* <p>
|
||||
* Note that it is at the discretion of the ACME server to accept this parameter.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public void setCommonName(String cn) {
|
||||
namebuilder.addRDN(BCStyle.CN, requireNonNull(cn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the organization.
|
||||
* <p>
|
||||
|
|
|
@ -126,18 +126,11 @@ public final class CertificateUtils {
|
|||
|
||||
var gns = new GeneralName[1];
|
||||
|
||||
switch (id.getType()) {
|
||||
case Identifier.TYPE_DNS:
|
||||
gns[0] = new GeneralName(GeneralName.dNSName, id.getDomain());
|
||||
break;
|
||||
|
||||
case Identifier.TYPE_IP:
|
||||
gns[0] = new GeneralName(GeneralName.iPAddress, id.getIP().getHostAddress());
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported Identifier type " + id.getType());
|
||||
}
|
||||
gns[0] = switch (id.getType()) {
|
||||
case Identifier.TYPE_DNS -> new GeneralName(GeneralName.dNSName, id.getDomain());
|
||||
case Identifier.TYPE_IP -> new GeneralName(GeneralName.iPAddress, id.getIP().getHostAddress());
|
||||
default -> throw new IllegalArgumentException("Unsupported Identifier type " + id.getType());
|
||||
};
|
||||
certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns));
|
||||
certBuilder.addExtension(ACME_VALIDATION, true, new DEROctetString(acmeValidation));
|
||||
|
||||
|
@ -268,9 +261,10 @@ public final class CertificateUtils {
|
|||
var attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest);
|
||||
if (attr.length > 0) {
|
||||
var extensions = attr[0].getAttrValues().toArray();
|
||||
if (extensions.length > 0 && extensions[0] instanceof Extensions) {
|
||||
var san = GeneralNames.fromExtensions((Extensions) extensions[0], Extension.subjectAlternativeName);
|
||||
certBuilder.addExtension(Extension.subjectAlternativeName, false, san);
|
||||
if (extensions.length > 0 && extensions[0] instanceof Extensions extension0) {
|
||||
var san = GeneralNames.fromExtensions(extension0, Extension.subjectAlternativeName);
|
||||
var critical = csr.getSubject().getRDNs().length == 0;
|
||||
certBuilder.addExtension(Extension.subjectAlternativeName, critical, san);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,22 @@
|
|||
|
||||
# Actalis: https://www.actalis.com/
|
||||
org.shredzone.acme4j.provider.actalis.ActalisAcmeProvider
|
||||
|
||||
# Buypass: https://buypass.com/
|
||||
org.shredzone.acme4j.provider.buypass.BuypassAcmeProvider
|
||||
|
||||
# Google Trust Services: https://pki.goog/
|
||||
org.shredzone.acme4j.provider.google.GoogleAcmeProvider
|
||||
|
||||
# Let's Encrypt: https://letsencrypt.org
|
||||
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider
|
||||
|
||||
# Pebble (ACME Test Server): https://github.com/letsencrypt/pebble
|
||||
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider
|
||||
|
||||
# SSL.com: https://ssl.com
|
||||
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider
|
||||
|
||||
# ZeroSSL: https://zerossl.com
|
||||
org.shredzone.acme4j.provider.zerossl.ZeroSSLAcmeProvider
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
|
||||
AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
|
||||
MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
|
||||
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
|
||||
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
|
||||
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
|
||||
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
|
||||
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
|
||||
AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
|
||||
BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
|
||||
d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
|
||||
WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
|
||||
xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
|
||||
Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
|
||||
2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
|
||||
p9BI7gVKtWSZYegicA==
|
||||
-----END CERTIFICATE-----
|
Binary file not shown.
|
@ -15,15 +15,22 @@ 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.assertThatException;
|
||||
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.security.KeyPair;
|
||||
import java.util.Optional;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import org.jose4j.jwx.CompactSerializer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mockito;
|
||||
import org.shredzone.acme4j.connector.Resource;
|
||||
import org.shredzone.acme4j.provider.TestableConnectionProvider;
|
||||
|
@ -105,11 +112,34 @@ public class AccountBuilderTest {
|
|||
/**
|
||||
* Test if a new account with Key Identifier can be created.
|
||||
*/
|
||||
@Test
|
||||
public void testRegistrationWithKid() throws Exception {
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
// Derived from key size
|
||||
"SHA-256,HS256,,",
|
||||
"SHA-384,HS384,,",
|
||||
"SHA-512,HS512,,",
|
||||
|
||||
// Enforced, but same as key size
|
||||
"SHA-256,HS256,HS256,",
|
||||
"SHA-384,HS384,HS384,",
|
||||
"SHA-512,HS512,HS512,",
|
||||
|
||||
// Enforced, different from key size
|
||||
"SHA-512,HS256,HS256,",
|
||||
|
||||
// Proposed by provider
|
||||
"SHA-256,HS256,,HS256",
|
||||
"SHA-512,HS256,,HS256",
|
||||
"SHA-512,HS512,HS512,HS256",
|
||||
})
|
||||
public void testRegistrationWithKid(String keyAlg,
|
||||
String expectedMacAlg,
|
||||
@Nullable String macAlg,
|
||||
@Nullable String providerAlg
|
||||
) throws Exception {
|
||||
var accountKey = TestUtils.createKeyPair();
|
||||
var keyIdentifier = "NCC-1701";
|
||||
var macKey = TestUtils.createSecretKey("SHA-256");
|
||||
var macKey = TestUtils.createSecretKey(keyAlg);
|
||||
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
|
@ -127,7 +157,7 @@ public class AccountBuilderTest {
|
|||
var encodedPayload = binding.get("payload").asString();
|
||||
var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
|
||||
|
||||
JoseUtilsTest.assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey);
|
||||
JoseUtilsTest.assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey, expectedMacAlg);
|
||||
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
@ -141,13 +171,22 @@ public class AccountBuilderTest {
|
|||
public JSON readJsonResponse() {
|
||||
return JSON.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getProposedEabMacAlgorithm() {
|
||||
return Optional.ofNullable(providerAlg);
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl);
|
||||
provider.putMetadata("externalAccountRequired", true);
|
||||
|
||||
var builder = new AccountBuilder();
|
||||
builder.useKeyPair(accountKey);
|
||||
builder.withKeyIdentifier(keyIdentifier, AcmeUtils.base64UrlEncode(macKey.getEncoded()));
|
||||
if (macAlg != null) {
|
||||
builder.withMacAlgorithm(macAlg);
|
||||
}
|
||||
|
||||
var session = provider.createSession();
|
||||
var login = builder.createLogin(session);
|
||||
|
@ -157,6 +196,18 @@ public class AccountBuilderTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if invalid mac algorithms are rejected.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {"foo", "null", "false", "none", "HS-256", "hs256", "HS128", "RS256"})
|
||||
public void testRejectInvalidMacAlg(@Nullable String macAlg) {
|
||||
assertThatException().isThrownBy(() -> {
|
||||
new AccountBuilder().withMacAlgorithm(macAlg);
|
||||
}).isInstanceOfAny(IllegalArgumentException.class, NullPointerException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if an existing account is properly returned.
|
||||
*/
|
||||
|
|
|
@ -96,16 +96,11 @@ public class AccountTest {
|
|||
public Collection<URL> getLinks(String relation) {
|
||||
return emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
var account = new Account(login);
|
||||
account.update();
|
||||
account.fetch();
|
||||
|
||||
assertThat(login.getAccountLocation()).isEqualTo(locationUrl);
|
||||
assertThat(account.getLocation()).isEqualTo(locationUrl);
|
||||
|
@ -151,15 +146,10 @@ public class AccountTest {
|
|||
|
||||
@Override
|
||||
public Collection<URL> getLinks(String relation) {
|
||||
switch(relation) {
|
||||
case "termsOfService": return singletonList(agreementUrl);
|
||||
default: return emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
// do nothing
|
||||
return switch (relation) {
|
||||
case "termsOfService" -> singletonList(agreementUrl);
|
||||
default -> emptyList();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -213,7 +203,7 @@ public class AccountTest {
|
|||
var domainName = "example.org";
|
||||
|
||||
var account = new Account(login);
|
||||
var auth = account.preAuthorizeDomain(domainName);
|
||||
var auth = account.preAuthorize(Identifier.dns(domainName));
|
||||
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
|
||||
|
@ -227,6 +217,77 @@ public class AccountTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that pre-authorization with subdomains fails if not supported.
|
||||
*/
|
||||
@Test
|
||||
public void testPreAuthorizeDomainSubdomainsFails() throws Exception {
|
||||
var provider = new TestableConnectionProvider();
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
|
||||
|
||||
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
|
||||
|
||||
var account = new Account(login);
|
||||
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
|
||||
account.preAuthorize(Identifier.dns("example.org").allowSubdomainAuth())
|
||||
);
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a domain can be pre-authorized, with allowed subdomains.
|
||||
*/
|
||||
@Test
|
||||
public void testPreAuthorizeDomainSubdomains() throws Exception {
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
assertThat(url).isEqualTo(resourceUrl);
|
||||
assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequestSub").toString());
|
||||
assertThat(login).isNotNull();
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("newAuthorizationResponseSub");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putMetadata("subdomainAuthAllowed", true);
|
||||
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
|
||||
provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
|
||||
|
||||
var domainName = "example.org";
|
||||
|
||||
var account = new Account(login);
|
||||
var auth = account.preAuthorize(Identifier.dns(domainName).allowSubdomainAuth());
|
||||
|
||||
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isTrue();
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
|
||||
assertThat(auth.getExpires()).isEmpty();
|
||||
assertThat(auth.getLocation()).isEqualTo(locationUrl);
|
||||
assertThat(auth.isSubdomainAuthAllowed()).isTrue();
|
||||
|
||||
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
|
||||
provider.getChallenge(Dns01Challenge.TYPE));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a domain pre-authorization can fail.
|
||||
*/
|
||||
|
|
|
@ -17,9 +17,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
|
||||
import static org.shredzone.acme4j.toolbox.TestUtils.url;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.shredzone.acme4j.toolbox.TestUtils;
|
||||
|
||||
|
@ -43,6 +48,7 @@ public class AcmeJsonResourceTest {
|
|||
assertThat(resource.getSession()).isEqualTo(login.getSession());
|
||||
assertThat(resource.getLocation()).isEqualTo(LOCATION_URL);
|
||||
assertThat(resource.isValid()).isFalse();
|
||||
assertThat(resource.getRetryAfter()).isEmpty();
|
||||
assertUpdateInvoked(resource, 0);
|
||||
|
||||
assertThat(resource.getJSON()).isEqualTo(JSON_DATA);
|
||||
|
@ -74,6 +80,25 @@ public class AcmeJsonResourceTest {
|
|||
assertUpdateInvoked(resource, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Retry-After
|
||||
*/
|
||||
@Test
|
||||
public void testRetryAfter() {
|
||||
var login = TestUtils.login();
|
||||
var retryAfter = Instant.now().plusSeconds(30L);
|
||||
var jsonData = getJSON("requestOrderResponse");
|
||||
|
||||
var resource = new DummyJsonResource(login, LOCATION_URL, jsonData, retryAfter);
|
||||
assertThat(resource.isValid()).isTrue();
|
||||
assertThat(resource.getJSON()).isEqualTo(jsonData);
|
||||
assertThat(resource.getRetryAfter()).hasValue(retryAfter);
|
||||
assertUpdateInvoked(resource, 0);
|
||||
|
||||
resource.setRetryAfter(null);
|
||||
assertThat(resource.getRetryAfter()).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test {@link AcmeJsonResource#invalidate()}.
|
||||
*/
|
||||
|
@ -116,6 +141,7 @@ public class AcmeJsonResourceTest {
|
|||
* Minimum implementation of {@link AcmeJsonResource}.
|
||||
*/
|
||||
private static class DummyJsonResource extends AcmeJsonResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = -6459238185161771948L;
|
||||
|
||||
private int updateCount = 0;
|
||||
|
@ -124,17 +150,19 @@ public class AcmeJsonResourceTest {
|
|||
super(login, location);
|
||||
}
|
||||
|
||||
public DummyJsonResource(Login login, URL location, JSON json) {
|
||||
public DummyJsonResource(Login login, URL location, JSON json, @Nullable Instant retryAfter) {
|
||||
super(login, location);
|
||||
setJSON(json);
|
||||
setRetryAfter(retryAfter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update() {
|
||||
// update() is tested individually in all AcmeJsonResource subclasses.
|
||||
public Optional<Instant> fetch() throws AcmeException {
|
||||
// fetch() is tested individually in all AcmeJsonResource subclasses.
|
||||
// Here we just simulate the update, by setting a JSON.
|
||||
updateCount++;
|
||||
setJSON(JSON_DATA);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ import java.io.ByteArrayInputStream;
|
|||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.io.Serial;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
|
@ -37,7 +39,7 @@ public class AcmeResourceTest {
|
|||
@Test
|
||||
public void testConstructor() throws Exception {
|
||||
var login = TestUtils.login();
|
||||
var location = new URL("http://example.com/acme/resource");
|
||||
var location = URI.create("http://example.com/acme/resource").toURL();
|
||||
|
||||
assertThrows(NullPointerException.class, () -> new DummyResource(null, null));
|
||||
|
||||
|
@ -52,7 +54,7 @@ public class AcmeResourceTest {
|
|||
@Test
|
||||
public void testSerialization() throws Exception {
|
||||
var login = TestUtils.login();
|
||||
var location = new URL("http://example.com/acme/resource");
|
||||
var location = URI.create("http://example.com/acme/resource").toURL();
|
||||
|
||||
// Create a Challenge for testing
|
||||
var challenge = new DummyResource(login, location);
|
||||
|
@ -99,7 +101,7 @@ public class AcmeResourceTest {
|
|||
public void testRebind() {
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
var login = TestUtils.login();
|
||||
var location = new URL("http://example.com/acme/resource");
|
||||
var location = URI.create("http://example.com/acme/resource").toURL();
|
||||
|
||||
var resource = new DummyResource(login, location);
|
||||
assertThat(resource.getLogin()).isEqualTo(login);
|
||||
|
@ -113,6 +115,7 @@ public class AcmeResourceTest {
|
|||
* Minimum implementation of {@link AcmeResource}.
|
||||
*/
|
||||
private static class DummyResource extends AcmeResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 7188822681353082472L;
|
||||
public DummyResource(Login login, URL location) {
|
||||
super(login, location);
|
||||
|
|
|
@ -25,6 +25,7 @@ import java.net.URL;
|
|||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -32,9 +33,7 @@ import org.shredzone.acme4j.challenge.Challenge;
|
|||
import org.shredzone.acme4j.challenge.Dns01Challenge;
|
||||
import org.shredzone.acme4j.challenge.Http01Challenge;
|
||||
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.provider.TestableConnectionProvider;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
||||
|
@ -128,11 +127,6 @@ public class AuthorizationTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateAuthorizationResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
// Just do nothing
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
@ -142,7 +136,7 @@ public class AuthorizationTest {
|
|||
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
|
||||
|
||||
var auth = new Authorization(login, locationUrl);
|
||||
auth.update();
|
||||
auth.fetch();
|
||||
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
|
||||
|
@ -174,11 +168,6 @@ public class AuthorizationTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateAuthorizationWildcardResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
// Just do nothing
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
@ -186,7 +175,7 @@ public class AuthorizationTest {
|
|||
provider.putTestChallenge("dns-01", Dns01Challenge::new);
|
||||
|
||||
var auth = new Authorization(login, locationUrl);
|
||||
auth.update();
|
||||
auth.fetch();
|
||||
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
|
||||
|
@ -219,11 +208,6 @@ public class AuthorizationTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateAuthorizationResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
// Just do nothing
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
@ -270,8 +254,8 @@ public class AuthorizationTest {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) throws AcmeException {
|
||||
throw new AcmeRetryAfterException(message, retryAfter);
|
||||
public Optional<Instant> getRetryAfter() {
|
||||
return Optional.of(retryAfter);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -282,8 +266,8 @@ public class AuthorizationTest {
|
|||
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
|
||||
|
||||
var auth = new Authorization(login, locationUrl);
|
||||
var ex = assertThrows(AcmeRetryAfterException.class, auth::update);
|
||||
assertThat(ex.getRetryAfter()).isEqualTo(retryAfter);
|
||||
var returnedRetryAfter = auth.fetch();
|
||||
assertThat(returnedRetryAfter).hasValue(retryAfter);
|
||||
|
||||
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
|
||||
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
|
||||
|
|
|
@ -21,6 +21,7 @@ import java.io.ByteArrayOutputStream;
|
|||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.security.KeyPair;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
@ -284,10 +285,10 @@ public class CertificateTest {
|
|||
*/
|
||||
@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
|
||||
// certid-cert.pem and certId provided by ACME ARI specs and known good
|
||||
var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
|
||||
var certIdCert = TestUtils.createCertificate("/certid-cert.pem");
|
||||
var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId);
|
||||
var certResourceUrl = URI.create(resourceUrl.toExternalForm() + "/" + certId).toURL();
|
||||
var retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS);
|
||||
|
||||
var provider = new TestableConnectionProvider() {
|
||||
|
@ -337,16 +338,13 @@ public class CertificateTest {
|
|||
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);
|
||||
.hasValue(certResourceUrl);
|
||||
|
||||
var renewalInfo = cert.getRenewalInfo();
|
||||
assertThat(renewalInfo.getRecheckAfter())
|
||||
.isNotEmpty()
|
||||
.contains(retryAfterInstant);
|
||||
assertThat(renewalInfo.getRetryAfter())
|
||||
.isEmpty();
|
||||
assertThat(renewalInfo.getSuggestedWindowStart())
|
||||
.isEqualTo("2021-01-03T00:00:00Z");
|
||||
assertThat(renewalInfo.getSuggestedWindowEnd())
|
||||
|
@ -355,6 +353,60 @@ public class CertificateTest {
|
|||
.isNotEmpty()
|
||||
.contains(url("https://example.com/docs/example-mass-reissuance-event"));
|
||||
|
||||
assertThat(renewalInfo.fetch()).hasValue(retryAfterInstant);
|
||||
assertThat(renewalInfo.getRetryAfter()).hasValue(retryAfterInstant);
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a certificate is marked as replaced.
|
||||
*/
|
||||
@Test
|
||||
public void testMarkedAsReplaced() throws AcmeException, IOException {
|
||||
// certid-cert.pem and certId provided by ACME ARI specs and known good
|
||||
var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
|
||||
var certIdCert = TestUtils.createCertificate("/certid-cert.pem");
|
||||
var certResourceUrl = URI.create(resourceUrl.toExternalForm() + "/" + certId).toURL();
|
||||
|
||||
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<X509Certificate> readCertificates() {
|
||||
assertThat(certRequested).isTrue();
|
||||
return certIdCert;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<URL> getLinks(String relation) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
|
||||
|
||||
var cert = new Certificate(provider.createLogin(), locationUrl);
|
||||
assertThat(cert.hasRenewalInfo()).isTrue();
|
||||
assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl);
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
@ -80,20 +80,20 @@ public class IdentifierTest {
|
|||
|
||||
@Test
|
||||
public void testIp() throws UnknownHostException {
|
||||
var id1 = Identifier.ip(InetAddress.getByName("192.168.1.2"));
|
||||
var id1 = Identifier.ip(InetAddress.getByName("192.0.2.2"));
|
||||
assertThat(id1.getType()).isEqualTo(Identifier.TYPE_IP);
|
||||
assertThat(id1.getValue()).isEqualTo("192.168.1.2");
|
||||
assertThat(id1.getIP().getHostAddress()).isEqualTo("192.168.1.2");
|
||||
assertThat(id1.getValue()).isEqualTo("192.0.2.2");
|
||||
assertThat(id1.getIP().getHostAddress()).isEqualTo("192.0.2.2");
|
||||
|
||||
var id2 = Identifier.ip(InetAddress.getByName("2001:db8:85a3::8a2e:370:7334"));
|
||||
assertThat(id2.getType()).isEqualTo(Identifier.TYPE_IP);
|
||||
assertThat(id2.getValue()).isEqualTo("2001:db8:85a3:0:0:8a2e:370:7334");
|
||||
assertThat(id2.getIP().getHostAddress()).isEqualTo("2001:db8:85a3:0:0:8a2e:370:7334");
|
||||
|
||||
var id3 = Identifier.ip("192.168.2.99");
|
||||
var id3 = Identifier.ip("192.0.2.99");
|
||||
assertThat(id3.getType()).isEqualTo(Identifier.TYPE_IP);
|
||||
assertThat(id3.getValue()).isEqualTo("192.168.2.99");
|
||||
assertThat(id3.getIP().getHostAddress()).isEqualTo("192.168.2.99");
|
||||
assertThat(id3.getValue()).isEqualTo("192.0.2.99");
|
||||
assertThat(id3.getIP().getHostAddress()).isEqualTo("192.0.2.99");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -103,12 +103,75 @@ public class IdentifierTest {
|
|||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAncestorDomain() {
|
||||
var id1 = Identifier.dns("foo.bar.example.com");
|
||||
var id1a = id1.withAncestorDomain("example.com");
|
||||
assertThat(id1a).isNotSameAs(id1);
|
||||
assertThat(id1a.getType()).isEqualTo(Identifier.TYPE_DNS);
|
||||
assertThat(id1a.getValue()).isEqualTo("foo.bar.example.com");
|
||||
assertThat(id1a.getDomain()).isEqualTo("foo.bar.example.com");
|
||||
assertThat(id1a.toMap()).contains(
|
||||
entry("type", "dns"),
|
||||
entry("value", "foo.bar.example.com"),
|
||||
entry("ancestorDomain", "example.com")
|
||||
);
|
||||
assertThat(id1a.toString()).isEqualTo("{ancestorDomain=example.com, type=dns, value=foo.bar.example.com}");
|
||||
|
||||
var id2 = Identifier.dns("föö.ëxämþlë.com").withAncestorDomain("ëxämþlë.com");
|
||||
assertThat(id2.getType()).isEqualTo(Identifier.TYPE_DNS);
|
||||
assertThat(id2.getValue()).isEqualTo("xn--f-1gaa.xn--xml-qla7ae5k.com");
|
||||
assertThat(id2.getDomain()).isEqualTo("xn--f-1gaa.xn--xml-qla7ae5k.com");
|
||||
assertThat(id2.toMap()).contains(
|
||||
entry("type", "dns"),
|
||||
entry("value", "xn--f-1gaa.xn--xml-qla7ae5k.com"),
|
||||
entry("ancestorDomain", "xn--xml-qla7ae5k.com")
|
||||
);
|
||||
|
||||
var id3 = Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com");
|
||||
assertThat(id3.equals(id1)).isFalse();
|
||||
assertThat(id3.equals(id1a)).isTrue();
|
||||
|
||||
assertThatExceptionOfType(AcmeProtocolException.class).isThrownBy(() ->
|
||||
Identifier.ip("192.0.2.99").withAncestorDomain("example.com")
|
||||
);
|
||||
|
||||
assertThatNullPointerException().isThrownBy(() ->
|
||||
Identifier.dns("example.org").withAncestorDomain(null)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAllowSubdomainAuth() {
|
||||
var id1 = Identifier.dns("example.com");
|
||||
var id1a = id1.allowSubdomainAuth();
|
||||
assertThat(id1a).isNotSameAs(id1);
|
||||
assertThat(id1a.getType()).isEqualTo(Identifier.TYPE_DNS);
|
||||
assertThat(id1a.getValue()).isEqualTo("example.com");
|
||||
assertThat(id1a.getDomain()).isEqualTo("example.com");
|
||||
assertThat(id1a.toMap()).contains(
|
||||
entry("type", "dns"),
|
||||
entry("value", "example.com"),
|
||||
entry("subdomainAuthAllowed", true)
|
||||
);
|
||||
assertThat(id1a.toString()).isEqualTo("{subdomainAuthAllowed=true, type=dns, value=example.com}");
|
||||
|
||||
var id3 = Identifier.dns("example.com").allowSubdomainAuth();
|
||||
assertThat(id3.equals(id1)).isFalse();
|
||||
assertThat(id3.equals(id1a)).isTrue();
|
||||
|
||||
assertThatExceptionOfType(AcmeProtocolException.class).isThrownBy(() ->
|
||||
Identifier.ip("192.0.2.99").allowSubdomainAuth()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEquals() {
|
||||
var idRef = new Identifier("foo", "123.456");
|
||||
|
||||
var id1 = new Identifier("foo", "123.456");
|
||||
assertThat(idRef.equals(id1)).isTrue();
|
||||
assertThat(id1.equals(idRef)).isTrue();
|
||||
|
||||
var id2 = new Identifier("bar", "654.321");
|
||||
assertThat(idRef.equals(id2)).isFalse();
|
||||
|
|
|
@ -21,6 +21,7 @@ import static org.shredzone.acme4j.toolbox.TestUtils.url;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -145,7 +146,7 @@ public class LoginTest {
|
|||
*/
|
||||
@Test
|
||||
public void testBindChallenge() throws Exception {
|
||||
var locationUrl = new URL("https://example.com/acme/challenge/1");
|
||||
var locationUrl = URI.create("https://example.com/acme/challenge/1").toURL();
|
||||
|
||||
var mockChallenge = mock(Http01Challenge.class);
|
||||
when(mockChallenge.getType()).thenReturn(Http01Challenge.TYPE);
|
||||
|
|
|
@ -14,12 +14,13 @@
|
|||
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.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
|
||||
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
|
||||
import static org.shredzone.acme4j.toolbox.TestUtils.url;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
|
@ -83,7 +84,7 @@ public class OrderBuilderTest {
|
|||
.identifier(Identifier.dns("d.example.com"))
|
||||
.identifiers(Arrays.asList(
|
||||
Identifier.dns("d2.example.com"),
|
||||
Identifier.ip(InetAddress.getByName("192.168.1.2"))))
|
||||
Identifier.ip(InetAddress.getByName("192.0.2.2"))))
|
||||
.notBefore(notBefore)
|
||||
.notAfter(notAfter)
|
||||
.create();
|
||||
|
@ -97,7 +98,7 @@ public class OrderBuilderTest {
|
|||
Identifier.dns("m.example.org"),
|
||||
Identifier.dns("d.example.com"),
|
||||
Identifier.dns("d2.example.com"),
|
||||
Identifier.ip(InetAddress.getByName("192.168.1.2")));
|
||||
Identifier.ip(InetAddress.getByName("192.0.2.2")));
|
||||
softly.assertThat(order.getNotBefore().orElseThrow())
|
||||
.isEqualTo("2016-01-01T00:10:00Z");
|
||||
softly.assertThat(order.getNotAfter().orElseThrow())
|
||||
|
@ -116,6 +117,8 @@ public class OrderBuilderTest {
|
|||
.isThrownBy(order::getAutoRenewalLifetimeAdjust);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(order::isAutoRenewalGetEnabled);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(order::getProfile);
|
||||
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
|
||||
softly.assertThat(order.getAuthorizations()).isNotNull();
|
||||
softly.assertThat(order.getAuthorizations()).hasSize(2);
|
||||
|
@ -156,7 +159,9 @@ public class OrderBuilderTest {
|
|||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putMetadata("auto-renewal", JSON.empty());
|
||||
provider.putMetadata("auto-renewal",JSON.parse(
|
||||
"{\"allow-certificate-get\": true}"
|
||||
).toMap());
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
var account = new Account(login);
|
||||
|
@ -186,6 +191,87 @@ public class OrderBuilderTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a new {@link Order} with ancestor domain can be created.
|
||||
*/
|
||||
@Test
|
||||
public void testOrderCertificateWithAncestor() throws Exception {
|
||||
var notBefore = parseTimestamp("2016-01-01T00:00:00Z");
|
||||
var notAfter = parseTimestamp("2016-01-08T00:00:00Z");
|
||||
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
assertThat(url).isEqualTo(resourceUrl);
|
||||
assertThatJson(claims.toString()).isEqualTo(getJSON("requestOrderRequestSub").toString());
|
||||
assertThat(login).isNotNull();
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("requestOrderResponseSub");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
provider.putMetadata("subdomainAuthAllowed", true);
|
||||
|
||||
var account = new Account(login);
|
||||
var order = account.newOrder()
|
||||
.identifier(Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com"))
|
||||
.notBefore(notBefore)
|
||||
.notAfter(notAfter)
|
||||
.create();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(
|
||||
Identifier.dns("foo.bar.example.com"));
|
||||
softly.assertThat(order.getNotBefore().orElseThrow())
|
||||
.isEqualTo("2016-01-01T00:10:00Z");
|
||||
softly.assertThat(order.getNotAfter().orElseThrow())
|
||||
.isEqualTo("2016-01-08T00:10:00Z");
|
||||
softly.assertThat(order.getExpires().orElseThrow())
|
||||
.isEqualTo("2016-01-10T00:00:00Z");
|
||||
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
|
||||
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
|
||||
softly.assertThat(order.getAuthorizations()).isNotNull();
|
||||
softly.assertThat(order.getAuthorizations()).hasSize(2);
|
||||
}
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a new {@link Order} with ancestor domain fails if not supported.
|
||||
*/
|
||||
@Test
|
||||
public void testOrderCertificateWithAncestorFails() throws Exception {
|
||||
var provider = new TestableConnectionProvider();
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
|
||||
|
||||
var account = new Account(login);
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
|
||||
account.newOrder()
|
||||
.identifier(Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com"))
|
||||
.create()
|
||||
);
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that an auto-renewal {@link Order} cannot be created if unsupported by the CA.
|
||||
*/
|
||||
|
@ -252,4 +338,164 @@ public class OrderBuilderTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a new profile {@link Order} can be created.
|
||||
*/
|
||||
@Test
|
||||
public void testProfileOrderCertificate() throws Exception {
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
assertThat(url).isEqualTo(resourceUrl);
|
||||
assertThatJson(claims.toString()).isEqualTo(getJSON("requestProfileOrderRequest").toString());
|
||||
assertThat(login).isNotNull();
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("requestProfileOrderResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putMetadata("profiles",JSON.parse(
|
||||
"{\"classic\": \"The same profile you're accustomed to\"}"
|
||||
).toMap());
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
var account = new Account(login);
|
||||
var order = account.newOrder()
|
||||
.domain("example.org")
|
||||
.profile("classic")
|
||||
.create();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(order.getProfile()).isEqualTo("classic");
|
||||
}
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a profile {@link Order} cannot be created if the profile is unsupported
|
||||
* by the CA.
|
||||
*/
|
||||
@Test
|
||||
public void testUnsupportedProfileOrderCertificateFails() throws Exception {
|
||||
var provider = new TestableConnectionProvider();
|
||||
provider.putMetadata("profiles",JSON.parse(
|
||||
"{\"classic\": \"The same profile you're accustomed to\"}"
|
||||
).toMap());
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
var account = new Account(login);
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
|
||||
account.newOrder()
|
||||
.domain("example.org")
|
||||
.profile("invalid")
|
||||
.create();
|
||||
}).withMessage("Server does not support profile: invalid");
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a profile {@link Order} cannot be created if the feature is unsupported
|
||||
* by the CA.
|
||||
*/
|
||||
@Test
|
||||
public void testProfileOrderCertificateFails() throws IOException {
|
||||
var provider = new TestableConnectionProvider();
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
var account = new Account(login);
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
|
||||
account.newOrder()
|
||||
.domain("example.org")
|
||||
.profile("classic")
|
||||
.create();
|
||||
}).withMessage("Server does not support profile");
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the ARI replaces field is set.
|
||||
*/
|
||||
@Test
|
||||
public void testARIReplaces() throws Exception {
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
assertThat(url).isEqualTo(resourceUrl);
|
||||
assertThatJson(claims.toString()).isEqualTo(getJSON("requestReplacesRequest").toString());
|
||||
assertThat(login).isNotNull();
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("requestReplacesResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
|
||||
|
||||
var account = new Account(login);
|
||||
account.newOrder()
|
||||
.domain("example.org")
|
||||
.replaces("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE")
|
||||
.create();
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that exception is thrown if the ARI replaces field is set but ARI is not
|
||||
* supported.
|
||||
*/
|
||||
@Test
|
||||
public void testARIReplaceFails() throws Exception {
|
||||
var provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
|
||||
fail("Request was sent");
|
||||
return HttpURLConnection.HTTP_FORBIDDEN;
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
var account = new Account(login);
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
|
||||
account.newOrder()
|
||||
.domain("example.org")
|
||||
.replaces("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE")
|
||||
.create();
|
||||
})
|
||||
.withMessage("Server does not support renewal-information");
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -56,17 +56,12 @@ public class OrderTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateOrderResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
assertThat(message).isNotNull();
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
var order = new Order(login, locationUrl);
|
||||
order.update();
|
||||
order.fetch();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
|
||||
|
@ -95,6 +90,8 @@ public class OrderTest {
|
|||
.isThrownBy(order::getAutoRenewalLifetimeAdjust);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(order::isAutoRenewalGetEnabled);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(order::getProfile);
|
||||
|
||||
softly.assertThat(order.getError()).isNotEmpty();
|
||||
softly.assertThat(order.getError().orElseThrow().getType())
|
||||
|
@ -133,11 +130,6 @@ public class OrderTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateOrderResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
assertThat(message).isNotNull();
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
@ -192,11 +184,6 @@ public class OrderTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
assertThat(message).isNotNull();
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
@ -216,10 +203,9 @@ public class OrderTest {
|
|||
.isEqualTo("2016-01-01T00:00:00Z");
|
||||
softly.assertThat(order.getNotAfter().orElseThrow())
|
||||
.isEqualTo("2016-01-08T00:00:00Z");
|
||||
softly.assertThat(order.isAutoRenewalCertificate()).isFalse();
|
||||
softly.assertThat(order.getCertificate().getLocation())
|
||||
.isEqualTo(url("https://example.com/acme/cert/1234"));
|
||||
softly.assertThatIllegalStateException()
|
||||
.isThrownBy(order::getAutoRenewalCertificate);
|
||||
softly.assertThat(order.getFinalizeLocation()).isEqualTo(finalizeUrl);
|
||||
|
||||
var auths = order.getAuthorizations();
|
||||
|
@ -250,11 +236,6 @@ public class OrderTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateAutoRenewOrderResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
assertThat(message).isNotNull();
|
||||
}
|
||||
};
|
||||
|
||||
provider.putMetadata("auto-renewal", JSON.empty());
|
||||
|
@ -262,7 +243,7 @@ public class OrderTest {
|
|||
var login = provider.createLogin();
|
||||
|
||||
var order = new Order(login, locationUrl);
|
||||
order.update();
|
||||
order.fetch();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(order.isAutoRenewing()).isTrue();
|
||||
|
@ -298,20 +279,14 @@ public class OrderTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("finalizeAutoRenewResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
assertThat(message).isNotNull();
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
var order = login.bindOrder(locationUrl);
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThatIllegalStateException()
|
||||
.isThrownBy(order::getCertificate);
|
||||
softly.assertThat(order.getAutoRenewalCertificate().getLocation())
|
||||
softly.assertThat(order.isAutoRenewalCertificate()).isTrue();
|
||||
softly.assertThat(order.getCertificate().getLocation())
|
||||
.isEqualTo(url("https://example.com/acme/cert/1234"));
|
||||
softly.assertThat(order.isAutoRenewing()).isTrue();
|
||||
softly.assertThat(order.getAutoRenewalStartDate().orElseThrow())
|
||||
|
|
|
@ -68,14 +68,12 @@ public class RenewalInfoTest {
|
|||
var login = provider.createLogin();
|
||||
|
||||
var renewalInfo = new RenewalInfo(login, locationUrl);
|
||||
renewalInfo.update();
|
||||
var recheckAfter = renewalInfo.fetch();
|
||||
assertThat(recheckAfter).hasValue(retryAfterInstant);
|
||||
|
||||
// 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())
|
||||
|
|
|
@ -21,7 +21,6 @@ import static org.shredzone.acme4j.toolbox.TestUtils.*;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Locale;
|
||||
|
@ -150,15 +149,15 @@ public class SessionTest {
|
|||
assertThat(session.hasDirectory()).isFalse();
|
||||
|
||||
assertThat(session.resourceUrl(Resource.NEW_ACCOUNT))
|
||||
.isEqualTo(new URL("https://example.com/acme/new-account"));
|
||||
.isEqualTo(URI.create("https://example.com/acme/new-account").toURL());
|
||||
|
||||
// There is a local copy of the directory now
|
||||
assertThat(session.hasDirectory()).isTrue();
|
||||
|
||||
assertThat(session.resourceUrl(Resource.NEW_AUTHZ))
|
||||
.isEqualTo(new URL("https://example.com/acme/new-authz"));
|
||||
.isEqualTo(URI.create("https://example.com/acme/new-authz").toURL());
|
||||
assertThat(session.resourceUrl(Resource.NEW_ORDER))
|
||||
.isEqualTo(new URL("https://example.com/acme/new-order"));
|
||||
.isEqualTo(URI.create("https://example.com/acme/new-order").toURL());
|
||||
|
||||
assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(() -> session.resourceUrl(Resource.REVOKE_CERT))
|
||||
|
@ -166,7 +165,7 @@ public class SessionTest {
|
|||
|
||||
assertThat(session.resourceUrlOptional(Resource.NEW_AUTHZ))
|
||||
.isNotEmpty()
|
||||
.contains(new URL("https://example.com/acme/new-authz"));
|
||||
.contains(URI.create("https://example.com/acme/new-authz").toURL());
|
||||
|
||||
assertThat(session.resourceUrlOptional(Resource.REVOKE_CERT))
|
||||
.isEmpty();
|
||||
|
@ -183,7 +182,16 @@ public class SessionTest {
|
|||
softly.assertThat(meta.getAutoRenewalMaxDuration()).isEqualTo(Duration.ofDays(365));
|
||||
softly.assertThat(meta.getAutoRenewalMinLifetime()).isEqualTo(Duration.ofHours(24));
|
||||
softly.assertThat(meta.isAutoRenewalGetAllowed()).isTrue();
|
||||
softly.assertThat(meta.isProfileAllowed()).isTrue();
|
||||
softly.assertThat(meta.isProfileAllowed("classic")).isTrue();
|
||||
softly.assertThat(meta.isProfileAllowed("custom")).isTrue();
|
||||
softly.assertThat(meta.isProfileAllowed("invalid")).isFalse();
|
||||
softly.assertThat(meta.getProfileDescription("classic")).contains("The profile you're accustomed to");
|
||||
softly.assertThat(meta.getProfileDescription("custom")).contains("Some other profile");
|
||||
softly.assertThat(meta.getProfiles()).contains("classic", "custom");
|
||||
softly.assertThat(meta.getProfileDescription("invalid")).isEmpty();
|
||||
softly.assertThat(meta.isExternalAccountRequired()).isTrue();
|
||||
softly.assertThat(meta.isSubdomainAuthAllowed()).isTrue();
|
||||
softly.assertThat(meta.getJSON()).isNotNull();
|
||||
}
|
||||
|
||||
|
@ -214,11 +222,11 @@ public class SessionTest {
|
|||
};
|
||||
|
||||
assertThat(session.resourceUrl(Resource.NEW_ACCOUNT))
|
||||
.isEqualTo(new URL("https://example.com/acme/new-account"));
|
||||
.isEqualTo(URI.create("https://example.com/acme/new-account").toURL());
|
||||
assertThat(session.resourceUrl(Resource.NEW_AUTHZ))
|
||||
.isEqualTo(new URL("https://example.com/acme/new-authz"));
|
||||
.isEqualTo(URI.create("https://example.com/acme/new-authz").toURL());
|
||||
assertThat(session.resourceUrl(Resource.NEW_ORDER))
|
||||
.isEqualTo(new URL("https://example.com/acme/new-order"));
|
||||
.isEqualTo(URI.create("https://example.com/acme/new-order").toURL());
|
||||
|
||||
var meta = session.getMetadata();
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
|
@ -227,12 +235,17 @@ public class SessionTest {
|
|||
softly.assertThat(meta.getWebsite()).isEmpty();
|
||||
softly.assertThat(meta.getCaaIdentities()).isEmpty();
|
||||
softly.assertThat(meta.isAutoRenewalEnabled()).isFalse();
|
||||
softly.assertThat(meta.isSubdomainAuthAllowed()).isFalse();
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(meta::getAutoRenewalMaxDuration);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(meta::getAutoRenewalMinLifetime);
|
||||
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
|
||||
.isThrownBy(meta::isAutoRenewalGetAllowed);
|
||||
softly.assertThat(meta.isProfileAllowed()).isFalse();
|
||||
softly.assertThat(meta.isProfileAllowed("classic")).isFalse();
|
||||
softly.assertThat(meta.getProfileDescription("classic")).isEmpty();
|
||||
softly.assertThat(meta.getProfiles()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ package org.shredzone.acme4j;
|
|||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
|
@ -27,8 +29,11 @@ public class StatusTest {
|
|||
*/
|
||||
@Test
|
||||
public void testParse() {
|
||||
// Would break toUpperCase() if English locale is not set, see #156.
|
||||
Locale.setDefault(new Locale("tr"));
|
||||
|
||||
for (var s : Status.values()) {
|
||||
var parsed = Status.parse(s.name().toLowerCase());
|
||||
var parsed = Status.parse(s.name().toLowerCase(Locale.ENGLISH));
|
||||
assertThat(parsed).isEqualTo(s);
|
||||
}
|
||||
|
||||
|
|
|
@ -26,14 +26,13 @@ import java.net.URL;
|
|||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
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.Login;
|
||||
import org.shredzone.acme4j.Status;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.provider.TestableConnectionProvider;
|
||||
import org.shredzone.acme4j.toolbox.JSON;
|
||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
||||
|
@ -89,7 +88,7 @@ public class ChallengeTest {
|
|||
@Test
|
||||
public void testNotAcceptable() {
|
||||
assertThrows(AcmeProtocolException.class, () ->
|
||||
new Http01Challenge(TestUtils.login(), getJSON("dnsChallenge"))
|
||||
new Http01Challenge(TestUtils.login(), getJSON("dns01Challenge"))
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -141,18 +140,13 @@ public class ChallengeTest {
|
|||
public JSON readJsonResponse() {
|
||||
return getJSON("updateHttpChallengeResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) {
|
||||
// Just do nothing
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse"));
|
||||
|
||||
challenge.update();
|
||||
challenge.fetch();
|
||||
|
||||
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
|
||||
assertThat(challenge.getLocation()).isEqualTo(locationUrl);
|
||||
|
@ -179,17 +173,17 @@ public class ChallengeTest {
|
|||
return getJSON("updateHttpChallengeResponse");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) throws AcmeException {
|
||||
throw new AcmeRetryAfterException(message, retryAfter);
|
||||
public Optional<Instant> getRetryAfter() {
|
||||
return Optional.of(retryAfter);
|
||||
}
|
||||
};
|
||||
|
||||
var login = provider.createLogin();
|
||||
|
||||
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse"));
|
||||
assertThrows(AcmeRetryAfterException.class, challenge::update);
|
||||
var returnedRetryAfter = challenge.fetch();
|
||||
assertThat(returnedRetryAfter).hasValue(retryAfter);
|
||||
|
||||
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
|
||||
assertThat(challenge.getLocation()).isEqualTo(locationUrl);
|
||||
|
|
|
@ -29,7 +29,7 @@ import org.shredzone.acme4j.toolbox.TestUtils;
|
|||
/**
|
||||
* Unit tests for {@link Dns01Challenge}.
|
||||
*/
|
||||
public class DnsChallengeTest {
|
||||
public class Dns01ChallengeTest {
|
||||
|
||||
private final Login login = TestUtils.login();
|
||||
|
||||
|
@ -38,29 +38,22 @@ public class DnsChallengeTest {
|
|||
*/
|
||||
@Test
|
||||
public void testDnsChallenge() {
|
||||
var challenge = new Dns01Challenge(login, getJSON("dnsChallenge"));
|
||||
var challenge = new Dns01Challenge(login, getJSON("dns01Challenge"));
|
||||
|
||||
assertThat(challenge.getType()).isEqualTo(Dns01Challenge.TYPE);
|
||||
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
|
||||
assertThat(challenge.getDigest()).isEqualTo("rzMmotrIgsithyBYc0vgiLUEEKYx0WetQRgEF2JIozA");
|
||||
assertThat(challenge.getAuthorization()).isEqualTo("pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0");
|
||||
|
||||
assertThat(challenge.getRRName("www.example.org")).isEqualTo("_acme-challenge.www.example.org.");
|
||||
assertThat(challenge.getRRName(Identifier.dns("www.example.org"))).isEqualTo("_acme-challenge.www.example.org.");
|
||||
assertThatExceptionOfType(AcmeProtocolException.class)
|
||||
.isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10")));
|
||||
|
||||
var response = new JSONBuilder();
|
||||
challenge.prepareResponse(response);
|
||||
|
||||
assertThatJson(response.toString()).isEqualTo("{}");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToRRName() {
|
||||
assertThat(Dns01Challenge.toRRName("www.example.org"))
|
||||
.isEqualTo("_acme-challenge.www.example.org.");
|
||||
assertThat(Dns01Challenge.toRRName(Identifier.dns("www.example.org")))
|
||||
.isEqualTo("_acme-challenge.www.example.org.");
|
||||
assertThatExceptionOfType(AcmeProtocolException.class)
|
||||
.isThrownBy(() -> Dns01Challenge.toRRName(Identifier.ip("127.0.0.10")));
|
||||
assertThat(Dns01Challenge.RECORD_NAME_PREFIX)
|
||||
.isEqualTo("_acme-challenge");
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2025 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.challenge;
|
||||
|
||||
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.getJSON;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.shredzone.acme4j.Identifier;
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.Status;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.toolbox.JSONBuilder;
|
||||
import org.shredzone.acme4j.toolbox.TestUtils;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link DnsAccount01Challenge}.
|
||||
*/
|
||||
class DnsAccount01ChallengeTest {
|
||||
|
||||
private final Login login = TestUtils.login();
|
||||
|
||||
/**
|
||||
* Test that {@link DnsAccount01Challenge} generates a correct authorization key.
|
||||
*/
|
||||
@Test
|
||||
public void testDnsChallenge() {
|
||||
var challenge = new DnsAccount01Challenge(login, getJSON("dnsAccount01Challenge"));
|
||||
|
||||
assertThat(challenge.getType()).isEqualTo(DnsAccount01Challenge.TYPE);
|
||||
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
|
||||
assertThat(challenge.getDigest()).isEqualTo("MSB8ZUQOmbNfHors7PG580PBz4f9hDuOPDN_j1bNcXI");
|
||||
assertThat(challenge.getAuthorization()).isEqualTo("ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0");
|
||||
|
||||
assertThat(challenge.getRRName("www.example.org"))
|
||||
.isEqualTo("_agozs7u2dml4wbyd._acme-challenge.www.example.org.");
|
||||
assertThat(challenge.getRRName(Identifier.dns("www.example.org")))
|
||||
.isEqualTo("_agozs7u2dml4wbyd._acme-challenge.www.example.org.");
|
||||
assertThatExceptionOfType(AcmeProtocolException.class)
|
||||
.isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10")));
|
||||
|
||||
var response = new JSONBuilder();
|
||||
challenge.prepareResponse(response);
|
||||
|
||||
assertThatJson(response.toString()).isEqualTo("{}");
|
||||
}
|
||||
|
||||
}
|
|
@ -28,7 +28,7 @@ import org.shredzone.acme4j.toolbox.TestUtils;
|
|||
/**
|
||||
* Unit tests for {@link Http01Challenge}.
|
||||
*/
|
||||
public class HttpChallengeTest {
|
||||
public class Http01ChallengeTest {
|
||||
private static final String TOKEN =
|
||||
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ";
|
||||
private static final String KEY_AUTHORIZATION =
|
|
@ -52,7 +52,6 @@ import org.shredzone.acme4j.Session;
|
|||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.exception.AcmeRateLimitedException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.exception.AcmeServerException;
|
||||
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
|
||||
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
|
||||
|
@ -89,9 +88,9 @@ public class DefaultConnectionTest {
|
|||
@BeforeEach
|
||||
public void setup(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
|
||||
baseUrl = wmRuntimeInfo.getHttpBaseUrl();
|
||||
directoryUrl = new URL(baseUrl + DIRECTORY_PATH);
|
||||
newNonceUrl = new URL(baseUrl + NEW_NONCE_PATH);
|
||||
requestUrl = new URL(baseUrl + REQUEST_PATH);
|
||||
directoryUrl = URI.create(baseUrl + DIRECTORY_PATH).toURL();
|
||||
newNonceUrl = URI.create(baseUrl + NEW_NONCE_PATH).toURL();
|
||||
requestUrl = URI.create(baseUrl + REQUEST_PATH).toURL();
|
||||
|
||||
session = new Session(directoryUrl.toURI());
|
||||
session.setLocale(Locale.JAPAN);
|
||||
|
@ -143,6 +142,53 @@ public class DefaultConnectionTest {
|
|||
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that {@link DefaultConnection#getNonce()} handles fails correctly.
|
||||
*/
|
||||
@Test
|
||||
public void testGetNonceFromHeaderFailed() throws AcmeException {
|
||||
var retryAfter = Instant.now().plusSeconds(30L).truncatedTo(SECONDS);
|
||||
|
||||
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(aResponse()
|
||||
.withStatus(HttpURLConnection.HTTP_UNAVAILABLE)
|
||||
.withHeader("Content-Type", "application/problem+json")
|
||||
// do not send a body here because it is a HEAD request!
|
||||
));
|
||||
|
||||
assertThat(session.getNonce()).isNull();
|
||||
|
||||
assertThatExceptionOfType(AcmeException.class).isThrownBy(() -> {
|
||||
try (var conn = session.connect()) {
|
||||
conn.resetNonce(session);
|
||||
}
|
||||
});
|
||||
|
||||
verify(headRequestedFor(urlEqualTo(NEW_NONCE_PATH)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that {@link DefaultConnection#getNonce()} handles a general HTTP error
|
||||
* correctly.
|
||||
*/
|
||||
@Test
|
||||
public void testGetNonceFromHeaderHttpError() {
|
||||
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(aResponse()
|
||||
.withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
|
||||
// do not send a body here because it is a HEAD request!
|
||||
));
|
||||
|
||||
assertThat(session.getNonce()).isNull();
|
||||
|
||||
var ex = assertThrows(AcmeException.class, () -> {
|
||||
try (var conn = session.connect()) {
|
||||
conn.resetNonce(session);
|
||||
}
|
||||
});
|
||||
assertThat(ex.getMessage()).isEqualTo("Server responded with HTTP 500 while trying to retrieve a nonce");
|
||||
|
||||
verify(headRequestedFor(urlEqualTo(NEW_NONCE_PATH)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that {@link DefaultConnection#getNonce()} fails on an invalid
|
||||
* {@code Replay-Nonce} header.
|
||||
|
@ -216,7 +262,7 @@ public class DefaultConnectionTest {
|
|||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
var location = conn.getLocation();
|
||||
assertThat(location).isEqualTo(new URL("https://example.com/otherlocation"));
|
||||
assertThat(location).isEqualTo(URI.create("https://example.com/otherlocation").toURL());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -232,7 +278,7 @@ public class DefaultConnectionTest {
|
|||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
var location = conn.getLocation();
|
||||
assertThat(location).isEqualTo(new URL(baseUrl + "/otherlocation"));
|
||||
assertThat(location).isEqualTo(URI.create(baseUrl + "/otherlocation").toURL());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -249,9 +295,9 @@ public class DefaultConnectionTest {
|
|||
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
assertThat(conn.getLinks("next")).containsExactly(new URL("https://example.com/acme/new-authz"));
|
||||
assertThat(conn.getLinks("recover")).containsExactly(new URL(baseUrl + "/recover-acct"));
|
||||
assertThat(conn.getLinks("terms-of-service")).containsExactly(new URL("https://example.com/acme/terms"));
|
||||
assertThat(conn.getLinks("next")).containsExactly(URI.create("https://example.com/acme/new-authz").toURL());
|
||||
assertThat(conn.getLinks("recover")).containsExactly(URI.create(baseUrl + "/recover-acct").toURL());
|
||||
assertThat(conn.getLinks("terms-of-service")).containsExactly(URI.create("https://example.com/acme/terms").toURL());
|
||||
assertThat(conn.getLinks("secret-stuff")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
@ -310,30 +356,24 @@ public class DefaultConnectionTest {
|
|||
* Test if Retry-After header with absolute date is correctly parsed.
|
||||
*/
|
||||
@Test
|
||||
public void testHandleRetryAfterHeaderDate() {
|
||||
public void testHandleRetryAfterHeaderDate() throws AcmeException {
|
||||
var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS);
|
||||
var retryMsg = "absolute date";
|
||||
|
||||
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
|
||||
.withHeader("Retry-After", DATE_FORMATTER.format(retryDate))
|
||||
));
|
||||
|
||||
var ex = assertThrows(AcmeRetryAfterException.class, () -> {
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
conn.handleRetryAfter(retryMsg);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(ex.getRetryAfter()).isEqualTo(retryDate);
|
||||
assertThat(ex.getMessage()).isEqualTo(retryMsg);
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
assertThat(conn.getRetryAfter()).hasValue(retryDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if Retry-After header with relative timespan is correctly parsed.
|
||||
*/
|
||||
@Test
|
||||
public void testHandleRetryAfterHeaderDelta() {
|
||||
public void testHandleRetryAfterHeaderDelta() throws AcmeException {
|
||||
var delta = 10 * 60 * 60;
|
||||
var now = Instant.now().truncatedTo(SECONDS);
|
||||
var retryMsg = "relative time";
|
||||
|
@ -343,15 +383,10 @@ public class DefaultConnectionTest {
|
|||
.withHeader("Date", DATE_FORMATTER.format(now))
|
||||
));
|
||||
|
||||
var ex = assertThrows(AcmeRetryAfterException.class, () -> {
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
conn.handleRetryAfter(retryMsg);
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(ex.getRetryAfter()).isEqualTo(now.plusSeconds(delta));
|
||||
assertThat(ex.getMessage()).isEqualTo(retryMsg);
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
assertThat(conn.getRetryAfter()).hasValue(now.plusSeconds(delta));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -365,7 +400,7 @@ public class DefaultConnectionTest {
|
|||
|
||||
try (var conn = session.connect()) {
|
||||
conn.sendRequest(requestUrl, session, null);
|
||||
conn.handleRetryAfter("no header");
|
||||
assertThat(conn.getRetryAfter()).isEmpty();
|
||||
}
|
||||
|
||||
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));
|
||||
|
|
|
@ -80,11 +80,6 @@ public class DummyConnection implements Connection {
|
|||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) throws AcmeException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getNonce() {
|
||||
throw new UnsupportedOperationException();
|
||||
|
|
|
@ -17,7 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.net.Authenticator;
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
|
@ -71,7 +71,7 @@ public class HttpConnectorTest {
|
|||
*/
|
||||
@Test
|
||||
public void testRequestBuilderDefaultValues() throws Exception {
|
||||
var url = new URL("http://example.org:123/foo");
|
||||
var url = URI.create("http://example.org:123/foo").toURL();
|
||||
var settings = new NetworkSettings();
|
||||
|
||||
var connector = new HttpConnector(settings);
|
||||
|
|
|
@ -36,7 +36,7 @@ public class NetworkSettingsTest {
|
|||
public void testGettersAndSetters() {
|
||||
var settings = new NetworkSettings();
|
||||
|
||||
var proxyAddress = new InetSocketAddress("10.0.0.1", 8080);
|
||||
var proxyAddress = new InetSocketAddress("198.51.100.1", 8080);
|
||||
var proxySelector = ProxySelector.of(proxyAddress);
|
||||
|
||||
assertThat(settings.getProxySelector()).isSameAs(HttpClient.Builder.NO_PROXY);
|
||||
|
@ -45,7 +45,7 @@ public class NetworkSettingsTest {
|
|||
settings.setProxySelector(null);
|
||||
assertThat(settings.getProxySelector()).isEqualTo(HttpClient.Builder.NO_PROXY);
|
||||
|
||||
assertThat(settings.getTimeout()).isEqualTo(Duration.ofSeconds(10));
|
||||
assertThat(settings.getTimeout()).isEqualTo(Duration.ofSeconds(30));
|
||||
settings.setTimeout(Duration.ofMillis(5120));
|
||||
assertThat(settings.getTimeout()).isEqualTo(Duration.ofMillis(5120));
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ package org.shredzone.acme4j.exception;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.net.URL;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -47,6 +48,7 @@ public class AcmeLazyLoadingExceptionTest {
|
|||
}
|
||||
|
||||
private static class TestResource extends AcmeResource {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1023419539450677538L;
|
||||
|
||||
public TestResource(Login login, URL location) {
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2016 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.exception;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AcmeRetryAfterException}.
|
||||
*/
|
||||
public class AcmeRetryAfterExceptionTest {
|
||||
|
||||
/**
|
||||
* Test that parameters are correctly returned.
|
||||
*/
|
||||
@Test
|
||||
public void testAcmeRetryAfterException() {
|
||||
var detail = "Too early";
|
||||
var retryAfter = Instant.now().plus(Duration.ofMinutes(1));
|
||||
|
||||
var ex = new AcmeRetryAfterException(detail, retryAfter);
|
||||
|
||||
assertThat(ex.getMessage()).isEqualTo(detail);
|
||||
assertThat(ex.getRetryAfter()).isEqualTo(retryAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that date is required.
|
||||
*/
|
||||
@Test
|
||||
public void testRequiredAcmeRetryAfterException() {
|
||||
assertThrows(NullPointerException.class, () -> {
|
||||
throw new AcmeRetryAfterException("null-test", null);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -18,7 +18,6 @@ import static org.shredzone.acme4j.toolbox.TestUtils.createProblem;
|
|||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
@ -35,7 +34,7 @@ public class AcmeUserActionRequiredExceptionTest {
|
|||
var type = URI.create("urn:ietf:params:acme:error:userActionRequired");
|
||||
var detail = "Accept new TOS";
|
||||
var tosUri = URI.create("http://example.com/agreement.pdf");
|
||||
var instanceUrl = new URL("http://example.com/howToAgree.html");
|
||||
var instanceUrl = URI.create("http://example.com/howToAgree.html").toURL();
|
||||
|
||||
var problem = createProblem(type, detail, instanceUrl);
|
||||
|
||||
|
@ -45,6 +44,7 @@ public class AcmeUserActionRequiredExceptionTest {
|
|||
assertThat(ex.getMessage()).isEqualTo(detail);
|
||||
assertThat(ex.getTermsOfServiceUri().orElseThrow()).isEqualTo(tosUri);
|
||||
assertThat(ex.getInstance()).isEqualTo(instanceUrl);
|
||||
assertThat(ex.toString()).isEqualTo("Please visit " + instanceUrl + " - details: " + detail);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -54,7 +54,7 @@ public class AcmeUserActionRequiredExceptionTest {
|
|||
public void testNullAcmeUserActionRequiredException() throws MalformedURLException {
|
||||
var type = URI.create("urn:ietf:params:acme:error:userActionRequired");
|
||||
var detail = "Call our service";
|
||||
var instanceUrl = new URL("http://example.com/howToContactUs.html");
|
||||
var instanceUrl = URI.create("http://example.com/howToContactUs.html").toURL();
|
||||
|
||||
var problem = createProblem(type, detail, instanceUrl);
|
||||
|
||||
|
@ -64,6 +64,7 @@ public class AcmeUserActionRequiredExceptionTest {
|
|||
assertThat(ex.getMessage()).isEqualTo(detail);
|
||||
assertThat(ex.getTermsOfServiceUri()).isEmpty();
|
||||
assertThat(ex.getInstance()).isEqualTo(instanceUrl);
|
||||
assertThat(ex.toString()).isEqualTo("Please visit " + instanceUrl + " - details: " + detail);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.shredzone.acme4j.Login;
|
|||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.challenge.Challenge;
|
||||
import org.shredzone.acme4j.challenge.Dns01Challenge;
|
||||
import org.shredzone.acme4j.challenge.DnsAccount01Challenge;
|
||||
import org.shredzone.acme4j.challenge.Http01Challenge;
|
||||
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
|
||||
import org.shredzone.acme4j.challenge.TokenChallenge;
|
||||
|
@ -243,13 +244,17 @@ public class AbstractAcmeProviderTest {
|
|||
var c2 = provider.createChallenge(login, getJSON("httpChallenge"));
|
||||
assertThat(c2).isNotSameAs(c1);
|
||||
|
||||
var c3 = provider.createChallenge(login, getJSON("dnsChallenge"));
|
||||
var c3 = provider.createChallenge(login, getJSON("dns01Challenge"));
|
||||
assertThat(c3).isNotNull();
|
||||
assertThat(c3).isInstanceOf(Dns01Challenge.class);
|
||||
|
||||
var c4 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
|
||||
var c4 = provider.createChallenge(login, getJSON("dnsAccount01Challenge"));
|
||||
assertThat(c4).isNotNull();
|
||||
assertThat(c4).isInstanceOf(TlsAlpn01Challenge.class);
|
||||
assertThat(c4).isInstanceOf(DnsAccount01Challenge.class);
|
||||
|
||||
var c5 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
|
||||
assertThat(c5).isNotNull();
|
||||
assertThat(c5).isInstanceOf(TlsAlpn01Challenge.class);
|
||||
|
||||
var json6 = new JSONBuilder()
|
||||
.put("type", "foobar-01")
|
||||
|
|
|
@ -44,7 +44,7 @@ public class GenericAcmeProviderTest {
|
|||
*/
|
||||
@Test
|
||||
public void testResolve() throws URISyntaxException {
|
||||
var serverUri = new URI("http://example.com/acme");
|
||||
var serverUri = new URI("http://example.com/acme?foo=abc&bar=123");
|
||||
|
||||
var provider = new GenericAcmeProvider();
|
||||
|
||||
|
|
|
@ -16,8 +16,10 @@ package org.shredzone.acme4j.provider;
|
|||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.shredzone.acme4j.Login;
|
||||
|
@ -106,7 +108,12 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP
|
|||
*/
|
||||
public Login createLogin() throws IOException {
|
||||
var session = createSession();
|
||||
return session.login(new URL(TestUtils.ACCOUNT_URL), TestUtils.createKeyPair());
|
||||
return session.login(URI.create(TestUtils.ACCOUNT_URL).toURL(), TestUtils.createKeyPair());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Instant> getRetryAfter() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2025 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.actalis;
|
||||
|
||||
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;
|
||||
|
||||
public class ActalisAcmeProviderTest {
|
||||
|
||||
private static final String PRODUCTION_DIRECTORY_URL = "https://acme-api.actalis.com/acme/directory";
|
||||
|
||||
/**
|
||||
* Tests if the provider accepts the correct URIs.
|
||||
*/
|
||||
@Test
|
||||
public void testAccepts() throws URISyntaxException {
|
||||
var provider = new ActalisAcmeProvider();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(provider.accepts(new URI("acme://actalis.com"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://actalis.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 ActalisAcmeProvider();
|
||||
|
||||
assertThat(provider.resolve(new URI("acme://actalis.com"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://actalis.com/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://letsencrypt.org/v99")));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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.buypass;
|
||||
|
||||
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 BuypassAcmeProvider}.
|
||||
*/
|
||||
public class BuypassAcmeProviderTest {
|
||||
|
||||
private static final String PRODUCTION_DIRECTORY_URL = "https://api.buypass.com/acme/directory";
|
||||
private static final String STAGING_DIRECTORY_URL = "https://api.test4.buypass.no/acme/directory";
|
||||
|
||||
/**
|
||||
* Tests if the provider accepts the correct URIs.
|
||||
*/
|
||||
@Test
|
||||
public void testAccepts() throws URISyntaxException {
|
||||
var provider = new BuypassAcmeProvider();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(provider.accepts(new URI("acme://buypass.com"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://buypass.com/"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://buypass.com/staging"))).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 BuypassAcmeProvider();
|
||||
|
||||
assertThat(provider.resolve(new URI("acme://buypass.com"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://buypass.com/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://buypass.com/staging"))).isEqualTo(url(STAGING_DIRECTORY_URL));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://buypass.com/v99")));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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.google;
|
||||
|
||||
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 GoogleAcmeProvider}.
|
||||
*/
|
||||
public class GoogleAcmeProviderTest {
|
||||
|
||||
private static final String PRODUCTION_DIRECTORY_URL = "https://dv.acme-v02.api.pki.goog/directory";
|
||||
private static final String STAGING_DIRECTORY_URL = "https://dv.acme-v02.test-api.pki.goog/directory";
|
||||
|
||||
/**
|
||||
* Tests if the provider accepts the correct URIs.
|
||||
*/
|
||||
@Test
|
||||
public void testAccepts() throws URISyntaxException {
|
||||
var provider = new GoogleAcmeProvider();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(provider.accepts(new URI("acme://pki.goog"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://pki.goog/"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://pki.goog/staging"))).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 GoogleAcmeProvider();
|
||||
|
||||
assertThat(provider.resolve(new URI("acme://pki.goog"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://pki.goog/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://pki.goog/staging"))).isEqualTo(url(STAGING_DIRECTORY_URL));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://pki.goog/v99")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if correct MAC algorithm is proposed.
|
||||
*/
|
||||
@Test
|
||||
public void testMacAlgorithm() {
|
||||
var provider = new GoogleAcmeProvider();
|
||||
|
||||
assertThat(provider.getProposedEabMacAlgorithm()).isNotEmpty().contains("HS256");
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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.sslcom;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
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 SslComAcmeProviderTest}.
|
||||
*/
|
||||
public class SslComAcmeProviderTest {
|
||||
|
||||
private static final String PRODUCTION_ECC_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-ecc";
|
||||
private static final String PRODUCTION_RSA_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-rsa";
|
||||
private static final String STAGING_ECC_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-ecc";
|
||||
private static final String STAGING_RSA_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-rsa";
|
||||
|
||||
/**
|
||||
* Tests if the provider accepts the correct URIs.
|
||||
*/
|
||||
@Test
|
||||
public void testAccepts() throws URISyntaxException {
|
||||
var provider = new SslComAcmeProvider();
|
||||
|
||||
try (var softly = new AutoCloseableSoftAssertions()) {
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com/"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com/ecc"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com/rsa"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com/staging"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com/staging/ecc"))).isTrue();
|
||||
softly.assertThat(provider.accepts(new URI("acme://ssl.com/staging/rsa"))).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 SslComAcmeProvider();
|
||||
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com"))).isEqualTo(url(PRODUCTION_ECC_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com/"))).isEqualTo(url(PRODUCTION_ECC_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com/ecc"))).isEqualTo(url(PRODUCTION_ECC_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com/rsa"))).isEqualTo(url(PRODUCTION_RSA_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com/staging"))).isEqualTo(url(STAGING_ECC_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com/staging/ecc"))).isEqualTo(url(STAGING_ECC_DIRECTORY_URL));
|
||||
assertThat(provider.resolve(new URI("acme://ssl.com/staging/rsa"))).isEqualTo(url(STAGING_RSA_DIRECTORY_URL));
|
||||
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> provider.resolve(new URI("acme://ssl.com/v99")));
|
||||
}
|
||||
|
||||
}
|
|
@ -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")));
|
||||
}
|
||||
|
||||
}
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.toolbox;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
@ -35,6 +36,7 @@ import org.junit.jupiter.api.BeforeAll;
|
|||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
@ -88,6 +90,23 @@ public class AcmeUtilsTest {
|
|||
assertThat(base64UrlDecode).isEqualTo(sha256hash("foobar"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test base32 encode.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@CsvSource({ // Test vectors according to RFC 4648 section 10
|
||||
"'',''",
|
||||
"f,MY======",
|
||||
"fo,MZXQ====",
|
||||
"foo,MZXW6===",
|
||||
"foob,MZXW6YQ=",
|
||||
"fooba,MZXW6YTB",
|
||||
"foobar,MZXW6YTBOI======",
|
||||
})
|
||||
public void testBase32Encode(String unencoded, String encoded) {
|
||||
assertThat(base32Encode(unencoded.getBytes(UTF_8))).isEqualTo(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test base64 URL validation for valid values
|
||||
*/
|
||||
|
|
|
@ -30,6 +30,8 @@ import org.jose4j.jws.JsonWebSignature;
|
|||
import org.jose4j.jwx.CompactSerializer;
|
||||
import org.jose4j.lang.JoseException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link JoseUtils}.
|
||||
|
@ -159,22 +161,23 @@ public class JoseUtilsTest {
|
|||
/**
|
||||
* Test if an external account binding is correctly created.
|
||||
*/
|
||||
@Test
|
||||
public void testCreateExternalAccountBinding() throws Exception {
|
||||
@ParameterizedTest
|
||||
@CsvSource({"SHA-256,HS256", "SHA-384,HS384", "SHA-512,HS512", "SHA-512,HS256"})
|
||||
public void testCreateExternalAccountBinding(String keyAlg, String macAlg) throws Exception {
|
||||
var accountKey = TestUtils.createKeyPair();
|
||||
var keyIdentifier = "NCC-1701";
|
||||
var macKey = TestUtils.createSecretKey("SHA-256");
|
||||
var macKey = TestUtils.createSecretKey(keyAlg);
|
||||
var resourceUrl = url("http://example.com/acme/resource");
|
||||
|
||||
var binding = JoseUtils.createExternalAccountBinding(
|
||||
keyIdentifier, accountKey.getPublic(), macKey, resourceUrl);
|
||||
keyIdentifier, accountKey.getPublic(), macKey, macAlg, resourceUrl);
|
||||
|
||||
var encodedHeader = binding.get("protected").toString();
|
||||
var encodedSignature = binding.get("signature").toString();
|
||||
var encodedPayload = binding.get("payload").toString();
|
||||
var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
|
||||
|
||||
assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey);
|
||||
assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey, macAlg);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -282,9 +285,12 @@ public class JoseUtilsTest {
|
|||
* Expected key identifier
|
||||
* @param macKey
|
||||
* Expected {@link SecretKey}
|
||||
* @param macAlg
|
||||
* Expected algorithm
|
||||
*/
|
||||
public static void assertExternalAccountBinding(String serialized, URL resourceUrl,
|
||||
String keyIdentifier, SecretKey macKey) {
|
||||
String keyIdentifier, SecretKey macKey,
|
||||
String macAlg) {
|
||||
try {
|
||||
var jws = new JsonWebSignature();
|
||||
jws.setCompactSerialization(serialized);
|
||||
|
@ -293,7 +299,7 @@ public class JoseUtilsTest {
|
|||
|
||||
assertThat(jws.getHeader("url")).isEqualTo(resourceUrl.toString());
|
||||
assertThat(jws.getHeader("kid")).isEqualTo(keyIdentifier);
|
||||
assertThat(jws.getHeader("alg")).isEqualTo("HS256");
|
||||
assertThat(jws.getHeader("alg")).isEqualTo(macAlg);
|
||||
|
||||
var decodedPayload = jws.getPayload();
|
||||
var expectedPayload = new StringBuilder();
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue